ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker workspace adapter в Launcher
This commit is contained in:
@@ -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)} />
|
||||
|
||||
Reference in New Issue
Block a user