From d4eba0ff3abddd3ab6e629dfe3055f21306385be Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 10:36:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20Tasker=20workspace=20adapter=20?= =?UTF-8?q?=D0=B2=20Launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/control-plane-store.mjs | 46 ++++ server/dev-server.mjs | 106 +++++++++ src/app/LauncherApp.tsx | 55 +++++ src/entities/client/types.ts | 6 + src/shared/api/adminApi.ts | 44 ++++ src/styles/globals.css | 33 +++ src/widgets/admin-overlay/AdminOverlay.tsx | 247 +++++++++++++++------ 7 files changed, 474 insertions(+), 63 deletions(-) diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 73d14c2..10de335 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -103,6 +103,7 @@ export function createControlPlaneStore({ projectRoot }) { demoEndsAt: nullableString(payload?.demoEndsAt), contactName: nullableString(payload?.contactName), contactEmail: nullableString(payload?.contactEmail), + integrations: normalizeClientIntegrations(payload?.integrations), notes: nullableString(payload?.notes), createdAt: now, updatedAt: now, @@ -138,6 +139,9 @@ export function createControlPlaneStore({ projectRoot }) { client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null); client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null); client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null); + if ("integrations" in (payload ?? {})) { + client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations); + } client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null); client.updatedAt = isoNow(); @@ -154,6 +158,28 @@ export function createControlPlaneStore({ projectRoot }) { return { client, data }; } + async function recordTaskManagerWorkspaceMembership(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const client = findById(data.clients, payload?.clientId, "client"); + const user = findById(data.users, payload?.userId, "user"); + const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {}; + const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {}; + const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {}; + + addAuditEvent(data, actor, { + action: "Назначен Tasker workspace", + objectType: "task-manager-membership", + objectName: user.name, + clientId: client.id, + result: "success", + details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${membership.role ?? payload?.role ?? "member"}`, + }); + + await writeData(data); + return { data }; + } + async function deleteClient(clientId, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -1028,6 +1054,7 @@ export function createControlPlaneStore({ projectRoot }) { reorderServices, retrySync, markUserAuthentikProvisioned, + recordTaskManagerWorkspaceMembership, setUserServiceAccess, updateClient, updateGroup, @@ -1052,6 +1079,10 @@ function normalizeData(payload) { } data.settings = normalizeSettings(data.settings); + data.clients = data.clients.map((client) => ({ + ...client, + integrations: normalizeClientIntegrations(client.integrations), + })); return data; } @@ -1074,6 +1105,21 @@ function normalizeSettings(payload) { }; } +function normalizeClientIntegrations(payload, fallback = {}) { + const integrations = typeof payload === "object" && payload !== null ? payload : {}; + const fallbackIntegrations = typeof fallback === "object" && fallback !== null ? fallback : {}; + const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {}; + const fallbackTaskManager = + typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {}; + + return { + taskManager: { + workspaceSlug: nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null), + workspaceName: nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null), + }, + }; +} + function resolveActor(data, identity) { const user = data.users.find( (item) => diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 618b710..6d25668 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -520,6 +520,64 @@ app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => { res.json({ clients: snapshot.data.clients }); }); +app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (_req, res) => { + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/"); + res.json(taskManager); +})); + +app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : ""; + const userId = typeof req.body?.userId === "string" ? req.body.userId : ""; + const client = snapshot.data.clients.find((candidate) => candidate.id === clientId); + const user = snapshot.data.users.find((candidate) => candidate.id === userId); + + if (!client) { + res.status(404).json({ ok: false, error: "client_not_found" }); + return; + } + + if (!user) { + res.status(404).json({ ok: false, error: "user_not_found" }); + return; + } + + const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); + const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null; + + if (!workspaceSlug) { + res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); + return; + } + + const role = normalizeTaskManagerRole(req.body?.role) ?? resolveTaskManagerRoleForMembership(membership?.role); + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", { + method: "POST", + body: { + workspaceSlug, + email: user.email, + subject: user.authentikUserId ?? undefined, + role, + companyRole: membership?.role ?? null, + setLastWorkspace: req.body?.setLastWorkspace !== false, + }, + }); + + const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership( + { + clientId: client.id, + userId: user.id, + workspaceSlug, + role, + taskManager, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); + res.json({ ...result, taskManager }); +})); + app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); res.status(201).json(result); @@ -1190,6 +1248,54 @@ function getTaskBaseUrl() { return taskBaseUrl.replace(/\/$/, ""); } +async function requestTaskManagerInternalJson(pathname, init = {}) { + if (!config.internalAccessToken) { + throw new Error("NODE.DC internal access token is not configured"); + } + + const targetUrl = new URL(pathname, `${getTaskBaseUrl()}/`); + const hasBody = typeof init.body === "object" && init.body !== null; + const response = await fetch(targetUrl, { + method: init.method ?? (hasBody ? "POST" : "GET"), + headers: { + Accept: "application/json", + Authorization: `Bearer ${config.internalAccessToken}`, + ...(hasBody ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + body: hasBody ? JSON.stringify(init.body) : undefined, + }); + const text = await response.text(); + const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {}; + + if (!response.ok) { + const error = typeof payload?.error === "string" ? payload.error : `Task Manager internal API failed: ${response.status}`; + throw new Error(error); + } + + return payload; +} + +function parseJsonResponse(text, url) { + try { + return JSON.parse(text); + } catch { + throw new Error(`Task Manager internal API returned non-JSON response: ${url}`); + } +} + +function normalizeOptionalText(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizeTaskManagerRole(value) { + return value === "admin" || value === "member" ? value : null; +} + +function resolveTaskManagerRoleForMembership(role) { + return role === "client_owner" || role === "client_admin" ? "admin" : "member"; +} + function createServiceHandoff(serviceSlug, user) { pruneExpiredServiceHandoffs(); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index ac7768b..fab9631 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -15,6 +15,8 @@ import { deleteAdminInvite, deleteAdminMembership, deleteAdminService, + ensureAdminTaskManagerWorkspaceMembership, + fetchAdminTaskManagerWorkspaces, fetchControlPlaneSnapshot, reorderAdminServices, retryAdminSync, @@ -27,6 +29,7 @@ import { updateAdminSettings, updateAdminUserProfile, type ControlPlaneMutationResult, + type TaskManagerWorkspaceSummary, } from "../shared/api/adminApi"; import { buildLauncherServices, @@ -81,6 +84,10 @@ export function LauncherApp() { const [authApps, setAuthApps] = useState(null); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); + const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState>({}); + const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState([]); + const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); + const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState(null); const [inviteFlow, setInviteFlow] = useState(() => (inviteToken ? { status: "loading" } : null)); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); @@ -322,6 +329,11 @@ export function LauncherApp() { }; }, [authSession]); + useEffect(() => { + if (!adminOpen || !authSession?.authenticated || !canUseAdminApi(authSession)) return; + void refreshTaskManagerWorkspaces(); + }, [adminOpen, authSession]); + useEffect(() => { if (!authSession?.authenticated) return; @@ -418,6 +430,20 @@ export function LauncherApp() { }); } + async function refreshTaskManagerWorkspaces() { + setTaskManagerWorkspacesLoading(true); + setTaskManagerWorkspacesError(null); + + try { + const result = await fetchAdminTaskManagerWorkspaces(); + setTaskManagerWorkspaces(result.workspaces ?? []); + } catch (error: unknown) { + setTaskManagerWorkspacesError(error instanceof Error ? error.message : "Не удалось загрузить workspace Operational Core"); + } finally { + setTaskManagerWorkspacesLoading(false); + } + } + function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) { const assignmentKey = accessAssignmentKey(userId, serviceId); @@ -441,6 +467,29 @@ export function LauncherApp() { }); } + function handleEnsureTaskManagerWorkspaceMember(command: { clientId: string; userId: string; role?: "member" | "admin" }) { + const membershipKey = `${command.clientId}:${command.userId}`; + + if (pendingTaskManagerMemberships[membershipKey]) { + return; + } + + setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true })); + ensureAdminTaskManagerWorkspaceMembership({ ...command, setLastWorkspace: true }) + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось назначить workspace Operational Core"); + }) + .finally(() => { + setPendingTaskManagerMemberships((current) => { + const { [membershipKey]: _completed, ...rest } = current; + return rest; + }); + }); + } + function handleCreateInvite(invite: Pick) { applyControlPlaneMutation(createAdminInvite(invite)); } @@ -683,6 +732,12 @@ export function LauncherApp() { onCreateService={handleCreateService} onDeleteService={handleDeleteService} onUpdateSettings={handleUpdateSettings} + taskManagerWorkspaces={taskManagerWorkspaces} + taskManagerWorkspacesLoading={taskManagerWorkspacesLoading} + taskManagerWorkspacesError={taskManagerWorkspacesError} + pendingTaskManagerMemberships={pendingTaskManagerMemberships} + onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} + onEnsureTaskManagerWorkspaceMember={handleEnsureTaskManagerWorkspaceMember} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( diff --git a/src/entities/client/types.ts b/src/entities/client/types.ts index 4a583b9..1627850 100644 --- a/src/entities/client/types.ts +++ b/src/entities/client/types.ts @@ -14,6 +14,12 @@ export interface Client { demoEndsAt?: string | null; contactName?: string | null; contactEmail?: string | null; + integrations?: { + taskManager?: { + workspaceSlug?: string | null; + workspaceName?: string | null; + }; + }; notes?: string | null; createdAt: string; updatedAt: string; diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 02c3bff..dc4963f 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -31,10 +31,42 @@ export interface ControlPlaneMutationResult { } | null; } +export interface TaskManagerWorkspaceSummary { + id: string; + slug: string; + name: string; + ownerEmail: string | null; + memberCount: number; +} + +export interface TaskManagerWorkspaceMembershipResult { + created: boolean; + workspace: TaskManagerWorkspaceSummary; + member: { + id: string; + email: string; + displayName: string; + }; + role: number; + isActive: boolean; + isBanned: boolean; +} + +export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult { + taskManager: { + ok: boolean; + membership: TaskManagerWorkspaceMembershipResult; + }; +} + export async function fetchControlPlaneSnapshot(): Promise { return requestJson("/api/admin/control-plane"); } +export async function fetchAdminTaskManagerWorkspaces(): Promise<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }> { + return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces"); +} + export async function createAdminClient(payload: Partial): Promise { return requestJson("/api/admin/clients", { method: "POST", @@ -100,6 +132,18 @@ export async function deleteAdminMembership(membershipId: string): Promise(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" }); } +export async function ensureAdminTaskManagerWorkspaceMembership(payload: { + clientId: string; + userId: string; + role?: "member" | "admin"; + setLastWorkspace?: boolean; +}): Promise { + return requestJson("/api/admin/task-manager/workspace-memberships/ensure", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function createAdminGroup(payload: Pick & Partial): Promise { return requestJson("/api/admin/groups", { method: "POST", diff --git a/src/styles/globals.css b/src/styles/globals.css index 35e1a29..f67782a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2194,6 +2194,39 @@ code { cursor: pointer; } +.admin-inline-action { + display: inline-flex; + min-height: 2.1rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(181, 255, 90, 0.92); + color: #050805; + padding: 0 0.85rem; + font-size: 0.72rem; + font-weight: 850; + cursor: pointer; +} + +.admin-inline-action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.admin-field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.6rem; + align-items: center; +} + +.service-content-field small { + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.35; +} + .service-status-dropdown { width: 7.45rem; min-width: 7.45rem; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index a4f33c4..b94ef95 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -64,6 +64,7 @@ import { type MeResponse, type TaskManagerWorkspaceCreationPolicy, } from "../../shared/api/mockApi"; +import type { TaskManagerWorkspaceSummary } from "../../shared/api/adminApi"; import { uploadStorageFile } from "../../shared/api/storageApi"; import { cn } from "../../shared/lib/cn"; import { formatDate, formatDateTime } from "../../shared/lib/format"; @@ -103,6 +104,12 @@ export interface CreateUserCommand { generatePassword: boolean; } +export interface EnsureTaskManagerWorkspaceMemberCommand { + clientId: string; + userId: string; + role?: "member" | "admin"; +} + const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, { id: "clients", label: "Клиенты", icon: }, @@ -152,6 +159,12 @@ export function AdminOverlay({ onCreateService, onDeleteService, onUpdateSettings, + taskManagerWorkspaces, + taskManagerWorkspacesLoading, + taskManagerWorkspacesError, + pendingTaskManagerMemberships, + onRefreshTaskManagerWorkspaces, + onEnsureTaskManagerWorkspaceMember, }: { data: LauncherData; me: MeResponse; @@ -178,6 +191,12 @@ export function AdminOverlay({ onCreateService: () => void; onDeleteService: (serviceId: string) => void; onUpdateSettings: (patch: Partial) => void; + taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; + taskManagerWorkspacesLoading: boolean; + taskManagerWorkspacesError: string | null; + pendingTaskManagerMemberships: Record; + onRefreshTaskManagerWorkspaces: () => void; + onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; }) { const isRoot = me.launcherRole === "root_admin"; const sections = isRoot ? rootSections : clientSections; @@ -289,7 +308,16 @@ export function AdminOverlay({
{activeSection === "overview" ? : null} {activeSection === "clients" && isRoot ? ( - + ) : null} {activeSection === "users" ? ( ) : null} {activeSection === "groups" ? ( @@ -399,11 +429,19 @@ function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clien function ClientsSection({ data, + taskManagerWorkspaces, + taskManagerWorkspacesLoading, + taskManagerWorkspacesError, + onRefreshTaskManagerWorkspaces, onCreateClient, onUpdateClient, onDeleteClient, }: { data: LauncherData; + taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; + taskManagerWorkspacesLoading: boolean; + taskManagerWorkspacesError: string | null; + onRefreshTaskManagerWorkspaces: () => void; onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; onDeleteClient: (clientId: string) => void; @@ -503,6 +541,10 @@ function ClientsSection({ {editingClient ? ( setEditingClientId(null)} onSave={(patch) => { onUpdateClient(editingClient.id, patch); @@ -527,6 +569,8 @@ function UsersSection({ onUpdateUser, onUpdateMembership, onDeleteMembership, + pendingTaskManagerMemberships, + onEnsureTaskManagerWorkspaceMember, }: { data: LauncherData; clientId: string; @@ -535,6 +579,8 @@ function UsersSection({ onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; + pendingTaskManagerMemberships: Record; + onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; }) { const [editingMembershipId, setEditingMembershipId] = useState(null); const [newUserEmail, setNewUserEmail] = useState(""); @@ -628,72 +674,91 @@ function UsersSection({ Группы Статус Доступ + Tasker - {rows.map(({ membership, user, client }) => ( - - - onUpdateUser(user.id, { name: event.target.value })} - aria-label={`Имя пользователя ${user.name}`} - /> - onUpdateUser(user.id, { email: event.target.value })} - aria-label={`Email пользователя ${user.name}`} - /> - - {isRoot ? {client.name} : null} - - onUpdateMembership(membership.id, { role })} - /> - - - {data.groups - .filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id)) - .map((group) => group.name) - .join(", ") || "—"} - - - onUpdateUser(user.id, { globalStatus: status })} - /> - - - onUpdateMembership(membership.id, { status })} - /> - - - setEditingMembershipId(membership.id)} - > - - - - - ))} + {rows.map(({ membership, user, client }) => { + const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null; + const pendingKey = `${client.id}:${user.id}`; + const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]); + const taskManagerRole = membership.role === "client_owner" || membership.role === "client_admin" ? "admin" : "member"; + + return ( + + + onUpdateUser(user.id, { name: event.target.value })} + aria-label={`Имя пользователя ${user.name}`} + /> + onUpdateUser(user.id, { email: event.target.value })} + aria-label={`Email пользователя ${user.name}`} + /> + + {isRoot ? {client.name} : null} + + onUpdateMembership(membership.id, { role })} + /> + + + {data.groups + .filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id)) + .map((group) => group.name) + .join(", ") || "—"} + + + onUpdateUser(user.id, { globalStatus: status })} + /> + + + onUpdateMembership(membership.id, { status })} + /> + + + + + + setEditingMembershipId(membership.id)} + > + + + + + ); + })} @@ -1489,18 +1554,35 @@ function ServiceContentModal({ function ClientEditorModal({ client, + taskManagerWorkspaces, + taskManagerWorkspacesLoading, + taskManagerWorkspacesError, + onRefreshTaskManagerWorkspaces, onClose, onSave, onDelete, canDelete, }: { client: Client; + taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; + taskManagerWorkspacesLoading: boolean; + taskManagerWorkspacesError: string | null; + onRefreshTaskManagerWorkspaces: () => void; onClose: () => void; onSave: (patch: Partial) => void; onDelete: () => void; canDelete: boolean; }) { const [draft, setDraft] = useState(client); + const taskManagerWorkspaceOptions: Array> = [ + { value: "none", label: "Не привязан" }, + ...taskManagerWorkspaces.map((workspace) => ({ + value: workspace.slug, + label: workspace.name, + description: `${workspace.slug} · ${workspace.memberCount} участников`, + })), + ]; + const selectedTaskManagerWorkspaceSlug = draft.integrations?.taskManager?.workspaceSlug ?? "none"; useEffect(() => setDraft(client), [client]); @@ -1508,6 +1590,22 @@ function ClientEditorModal({ setDraft((current) => ({ ...current, [key]: value })); } + function updateTaskManagerWorkspace(workspaceSlug: string) { + const selectedWorkspace = taskManagerWorkspaces.find((workspace) => workspace.slug === workspaceSlug); + + setDraft((current) => ({ + ...current, + integrations: { + ...current.integrations, + taskManager: { + ...current.integrations?.taskManager, + workspaceSlug: workspaceSlug === "none" ? null : workspaceSlug, + workspaceName: selectedWorkspace?.name ?? null, + }, + }, + })); + } + return (
@@ -1540,6 +1638,29 @@ function ClientEditorModal({ Статус update("status", status)} />
+
+ Operational Core workspace +
+ + +
+ + {taskManagerWorkspacesError + ? taskManagerWorkspacesError + : "Эта привязка используется для назначения участников клиента в workspace Task Manager."} + +