ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker workspace adapter в Launcher

This commit is contained in:
DCCONSTRUCTIONS
2026-05-06 10:36:20 +03:00
parent 897c7145f0
commit d4eba0ff3a
7 changed files with 474 additions and 63 deletions
+184 -63
View File
@@ -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: <LayoutDashboard size={16} /> },
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
@@ -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<LauncherSettings>) => void;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
pendingTaskManagerMemberships: Record<string, boolean>;
onRefreshTaskManagerWorkspaces: () => void;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const isRoot = me.launcherRole === "root_admin";
const sections = isRoot ? rootSections : clientSections;
@@ -289,7 +308,16 @@ export function AdminOverlay({
<div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? (
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
<ClientsSection
data={data}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onCreateClient={onCreateClient}
onUpdateClient={onUpdateClient}
onDeleteClient={onDeleteClient}
/>
) : null}
{activeSection === "users" ? (
<UsersSection
@@ -300,6 +328,8 @@ export function AdminOverlay({
onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onEnsureTaskManagerWorkspaceMember={onEnsureTaskManagerWorkspaceMember}
/>
) : 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<Client>) => void;
onDeleteClient: (clientId: string) => void;
@@ -503,6 +541,10 @@ function ClientsSection({
{editingClient ? (
<ClientEditorModal
client={editingClient}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onClose={() => 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<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState("");
@@ -628,72 +674,91 @@ function UsersSection({
<th>Группы</th>
<th>Статус</th>
<th>Доступ</th>
<th>Tasker</th>
<th aria-label="Редактирование" />
</tr>
</thead>
<tbody>
{rows.map(({ membership, user, client }) => (
<tr key={membership.id}>
<td className="services-admin-table__service">
<input
className="admin-table-input admin-table-input--strong"
value={user.name}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={user.email}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</td>
{isRoot ? <td>{client.name}</td> : null}
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={membership.role}
options={membershipRoleOptions}
label={`Роль ${user.name}`}
minMenuWidth={198}
onChange={(role) => onUpdateMembership(membership.id, { role })}
/>
</td>
<td>
{data.groups
.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id))
.map((group) => group.name)
.join(", ") || "—"}
</td>
<td>
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус пользователя ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
</td>
<td>
<AdminStatusDropdown
value={membership.status}
options={membershipStatusOptions}
label={`Доступ пользователя ${user.name}`}
onChange={(status) => onUpdateMembership(membership.id, { status })}
/>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Редактировать пользователя ${user.name}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setEditingMembershipId(membership.id)}
>
<Edit3 size={15} />
</IconButton>
</td>
</tr>
))}
{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 (
<tr key={membership.id}>
<td className="services-admin-table__service">
<input
className="admin-table-input admin-table-input--strong"
value={user.name}
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
aria-label={`Имя пользователя ${user.name}`}
/>
<input
className="admin-table-input admin-table-input--muted"
value={user.email}
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
aria-label={`Email пользователя ${user.name}`}
/>
</td>
{isRoot ? <td>{client.name}</td> : null}
<td>
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={membership.role}
options={membershipRoleOptions}
label={`Роль ${user.name}`}
minMenuWidth={198}
onChange={(role) => onUpdateMembership(membership.id, { role })}
/>
</td>
<td>
{data.groups
.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id))
.map((group) => group.name)
.join(", ") || "—"}
</td>
<td>
<AdminStatusDropdown
value={user.globalStatus}
options={userStatusOptions}
label={`Статус пользователя ${user.name}`}
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
/>
</td>
<td>
<AdminStatusDropdown
value={membership.status}
options={membershipStatusOptions}
label={`Доступ пользователя ${user.name}`}
onChange={(status) => onUpdateMembership(membership.id, { status })}
/>
</td>
<td>
<button
className="admin-inline-action"
type="button"
disabled={!taskManagerWorkspace || pendingTaskerAssignment || membership.status !== "active" || user.globalStatus !== "active"}
title={taskManagerWorkspace ? `Workspace: ${taskManagerWorkspace}` : "У клиента не выбран workspace Operational Core"}
onClick={() => onEnsureTaskManagerWorkspaceMember({ clientId: client.id, userId: user.id, role: taskManagerRole })}
>
{pendingTaskerAssignment ? "Назначаем" : taskManagerWorkspace ? "Назначить" : "Нет workspace"}
</button>
</td>
<td className="services-admin-table__actions">
<IconButton
label={`Редактировать пользователя ${user.name}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setEditingMembershipId(membership.id)}
>
<Edit3 size={15} />
</IconButton>
</td>
</tr>
);
})}
</tbody>
</table>
</GlassSurface>
@@ -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<Client>) => void;
onDelete: () => void;
canDelete: boolean;
}) {
const [draft, setDraft] = useState<Client>(client);
const taskManagerWorkspaceOptions: Array<NodeDcSelectOption<string>> = [
{ 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 (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
<article className="service-content-modal admin-entity-modal">
@@ -1540,6 +1638,29 @@ function ClientEditorModal({
<span>Статус</span>
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
</div>
<div className="service-content-field service-content-field--wide">
<span>Operational Core workspace</span>
<div className="admin-field-row">
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={selectedTaskManagerWorkspaceSlug}
options={taskManagerWorkspaceOptions}
label="Operational Core workspace"
searchable
minMenuWidth={280}
onChange={updateTaskManagerWorkspace}
/>
<button className="admin-inline-action" type="button" disabled={taskManagerWorkspacesLoading} onClick={onRefreshTaskManagerWorkspaces}>
{taskManagerWorkspacesLoading ? "Обновляем" : "Обновить"}
</button>
</div>
<small>
{taskManagerWorkspacesError
? taskManagerWorkspacesError
: "Эта привязка используется для назначения участников клиента в workspace Task Manager."}
</small>
</div>
<label className="service-content-field">
<span>Контактное лицо</span>
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />