diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 34fbfe4..a1f450f 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -3,15 +3,15 @@ { "id": "client_romashka", "type": "company", - "name": "ООО Ромашка", - "legalName": "ООО Ромашка", + "name": "DCTOUCH", + "legalName": "ООО ДИСИТАЧ", "status": "active", "demoEndsAt": null, "contactName": "Иван Петров", - "contactEmail": "ivan@romashka.ru", + "contactEmail": "suppert@dctouch.ru", "notes": "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T18:49:19.215Z" }, { "id": "client_roga_kopyta", @@ -102,7 +102,7 @@ "email": "oleg@romashka.ru", "globalStatus": "blocked", "createdAt": "2026-04-12T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-01T18:49:59.865Z" } ], "memberships": [ diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 86935d9..b9241c7 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import type { ServiceAccessException, ServiceGrant } from "../entities/access/types"; +import type { Client } from "../entities/client/types"; import type { Invite } from "../entities/invite/types"; import type { LauncherServiceView, Service } from "../entities/service/types"; import type { SyncStatus } from "../entities/sync/types"; +import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; import { buildLauncherServices, buildMe, @@ -169,6 +171,21 @@ export function LauncherApp() { })); } + function handleUpdateInvite(inviteId: string, patch: Partial) { + setData((current) => ({ + ...current, + invites: current.invites.map((invite) => + invite.id === inviteId + ? { + ...invite, + ...patch, + updatedAt: new Date().toISOString(), + } + : invite + ), + })); + } + function handleRetrySync(syncId: string) { setData((current) => ({ ...current, @@ -200,6 +217,111 @@ export function LauncherApp() { })); } + function handleCreateClient() { + const createdAt = new Date().toISOString(); + const index = data.clients.length + 1; + + setData((current) => ({ + ...current, + clients: [ + ...current.clients, + { + id: `client_mock_${Date.now()}`, + type: "company", + name: `Новый клиент ${index}`, + legalName: `Новый клиент ${index}`, + status: "demo", + demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), + contactName: "", + contactEmail: "", + notes: "", + createdAt, + updatedAt: createdAt, + }, + ], + })); + } + + function handleUpdateClient(clientId: string, patch: Partial) { + setData((current) => ({ + ...current, + clients: current.clients.map((client) => + client.id === clientId + ? { + ...client, + ...patch, + updatedAt: new Date().toISOString(), + } + : client + ), + })); + } + + function handleUpdateUser(userId: string, patch: Partial) { + setData((current) => ({ + ...current, + users: current.users.map((user) => + user.id === userId + ? { + ...user, + ...patch, + updatedAt: new Date().toISOString(), + } + : user + ), + })); + } + + function handleUpdateMembership(membershipId: string, patch: Partial) { + setData((current) => ({ + ...current, + memberships: current.memberships.map((membership) => + membership.id === membershipId + ? { + ...membership, + ...patch, + updatedAt: new Date().toISOString(), + } + : membership + ), + })); + } + + function handleCreateGroup(clientId: string) { + const createdAt = new Date().toISOString(); + + setData((current) => ({ + ...current, + groups: [ + ...current.groups, + { + id: `group_mock_${Date.now()}`, + clientId, + name: "Новая группа", + description: "Описание группы", + memberIds: [], + createdAt, + updatedAt: createdAt, + }, + ], + })); + } + + function handleUpdateGroup(groupId: string, patch: Partial) { + setData((current) => ({ + ...current, + groups: current.groups.map((group) => + group.id === groupId + ? { + ...group, + ...patch, + updatedAt: new Date().toISOString(), + } + : group + ), + })); + } + function handleReorderServices(orderedServiceIds: string[]) { setData((current) => { const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); @@ -304,7 +426,14 @@ export function LauncherApp() { onCreateDenyException={handleCreateDenyException} onRemoveException={handleRemoveException} onCreateInvite={handleCreateInvite} + onUpdateInvite={handleUpdateInvite} onRetrySync={handleRetrySync} + onCreateClient={handleCreateClient} + onUpdateClient={handleUpdateClient} + onUpdateUser={handleUpdateUser} + onUpdateMembership={handleUpdateMembership} + onCreateGroup={handleCreateGroup} + onUpdateGroup={handleUpdateGroup} onUpdateService={handleUpdateService} onReorderServices={handleReorderServices} onCreateService={handleCreateService} diff --git a/src/entities/client/types.ts b/src/entities/client/types.ts index 7d92873..4a583b9 100644 --- a/src/entities/client/types.ts +++ b/src/entities/client/types.ts @@ -6,7 +6,11 @@ export interface Client { type: ClientType; name: string; legalName?: string | null; + inn?: string | null; status: ClientStatus; + contractStartsAt?: string | null; + contractEndsAt?: string | null; + paidUntil?: string | null; demoEndsAt?: string | null; contactName?: string | null; contactEmail?: string | null; diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts index a42fcd4..8420fce 100644 --- a/src/entities/user/types.ts +++ b/src/entities/user/types.ts @@ -12,6 +12,9 @@ export interface LauncherUser { authentikUserId?: string | null; email: string; name: string; + phone?: string | null; + position?: string | null; + notes?: string | null; avatarUrl?: string | null; globalStatus: LauncherUserStatus; createdAt: string; diff --git a/src/styles/globals.css b/src/styles/globals.css index 7e11ef7..1bf8441 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1392,7 +1392,7 @@ code { } .admin-panel-content .admin-section-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .admin-panel-content .access-layout, @@ -1595,6 +1595,12 @@ code { color: rgb(var(--nodedc-on-accent-rgb)); } +.admin-circle-action:disabled { + cursor: default; + opacity: 0.36; + filter: grayscale(1); +} + .admin-section-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1776,6 +1782,12 @@ code { font-size: 0.72rem; } +.admin-table-input--select { + appearance: none; + background: rgba(255, 255, 255, 0.045); + cursor: pointer; +} + .service-status-dropdown { width: 7.45rem; min-width: 7.45rem; @@ -1912,6 +1924,218 @@ code { background: rgba(255, 120, 120, 0.32); } +.admin-status-dropdown { + width: 8.65rem; + min-width: 8.65rem; +} + +.admin-status-trigger { + display: inline-flex; + width: 8.65rem; + min-height: 2.08rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.075); + color: rgba(255, 255, 255, 0.84); + padding: 0 0.7rem; + font-size: 0.72rem; + font-weight: 850; + white-space: nowrap; +} + +.admin-status-trigger span { + width: 100%; + text-align: center; +} + +.admin-status-trigger[data-tone="green"] { + background: rgba(181, 255, 90, 0.075); + color: rgba(226, 255, 190, 0.94); +} + +.admin-status-trigger[data-tone="yellow"] { + background: rgba(246, 201, 95, 0.075); + color: rgba(255, 232, 178, 0.94); +} + +.admin-status-trigger[data-tone="violet"] { + background: rgba(210, 197, 255, 0.07); + color: rgba(232, 225, 255, 0.92); +} + +.admin-status-trigger[data-tone="red"] { + background: rgba(255, 120, 120, 0.07); + color: rgba(255, 216, 216, 0.92); +} + +.admin-status-trigger[data-tone="muted"] { + background: rgba(255, 255, 255, 0.075); + color: rgba(255, 255, 255, 0.78); +} + +.admin-status-trigger:hover, +.admin-status-trigger[aria-expanded="true"] { + filter: brightness(1.12); + color: var(--text-primary); +} + +.admin-status-trigger--static { + pointer-events: none; +} + +.admin-status-menu { + display: grid; + gap: 0.18rem; + min-width: 10.25rem; +} + +.admin-status-menu__option { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + min-height: 2.45rem; + align-items: center; + border: 0; + border-radius: 0.92rem; + background: transparent; + color: rgba(255, 255, 255, 0.68); + padding: 0 0.78rem; + text-align: left; + font-size: 0.8rem; + font-weight: 780; +} + +.admin-status-menu__option span { + text-align: left; +} + +.admin-status-menu__mark { + display: block; + width: 0.45rem; + height: 0.45rem; + border-radius: var(--launcher-radius-circle); + background: transparent; +} + +.admin-status-menu__option:hover, +.admin-status-menu__option:focus-visible { + background: rgba(255, 255, 255, 0.075); + color: var(--text-primary); + outline: none; +} + +.admin-status-menu__option[data-selected="true"] { + background: rgba(255, 255, 255, 0.11); + color: var(--text-primary); +} + +.admin-status-menu__option[data-selected="true"] .admin-status-menu__mark { + background: rgba(247, 248, 244, 0.96); +} + +.admin-status-menu__option[data-tone="green"][data-selected="true"] { + background: rgba(181, 255, 90, 0.065); + color: rgba(226, 255, 190, 0.96); +} + +.admin-status-menu__option[data-tone="yellow"][data-selected="true"] { + background: rgba(246, 201, 95, 0.065); + color: rgba(255, 232, 178, 0.96); +} + +.admin-status-menu__option[data-tone="violet"][data-selected="true"] { + background: rgba(210, 197, 255, 0.06); + color: rgba(232, 225, 255, 0.94); +} + +.admin-status-menu__option[data-tone="red"][data-selected="true"] { + background: rgba(255, 120, 120, 0.06); + color: rgba(255, 216, 216, 0.94); +} + +.admin-status-menu__option[data-tone="green"][data-selected="true"] .admin-status-menu__mark { + background: rgba(181, 255, 90, 0.34); +} + +.admin-status-menu__option[data-tone="yellow"][data-selected="true"] .admin-status-menu__mark { + background: rgba(246, 201, 95, 0.34); +} + +.admin-status-menu__option[data-tone="violet"][data-selected="true"] .admin-status-menu__mark { + background: rgba(210, 197, 255, 0.32); +} + +.admin-status-menu__option[data-tone="red"][data-selected="true"] .admin-status-menu__mark { + background: rgba(255, 120, 120, 0.32); +} + +.admin-date-field { + width: 100%; +} + +.admin-date-trigger { + display: inline-flex; + width: 100%; + min-height: 2.08rem; + align-items: center; + justify-content: flex-start; + gap: 0.42rem; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.052); + color: var(--text-secondary); + padding: 0 0.7rem; + font-size: 0.74rem; + font-weight: 760; +} + +.admin-date-trigger:hover, +.admin-date-trigger:focus-visible { + background: rgba(255, 255, 255, 0.085); + color: var(--text-primary); + outline: none; +} + +.admin-date-popover { + display: grid; + gap: 0.65rem; + min-width: 14rem; +} + +.admin-date-popover input { + min-height: 2.65rem; + border: 0; + border-radius: 0.92rem; + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); + padding: 0 0.78rem; + font: inherit; + color-scheme: dark; +} + +.admin-date-popover__actions { + display: flex; + justify-content: space-between; + gap: 0.45rem; +} + +.admin-date-popover__actions button { + min-height: 2.25rem; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + padding: 0 0.75rem; + font-size: 0.76rem; + font-weight: 780; +} + +.admin-date-popover__actions button:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); +} + .admin-table-select { appearance: none; border-radius: 999px; @@ -2036,7 +2260,8 @@ code { } .service-content-field input, -.service-content-field textarea { +.service-content-field textarea, +.service-content-field select { width: 100%; border: 0; border-radius: 1rem; @@ -2046,6 +2271,11 @@ code { font-size: 0.84rem; } +.service-content-field select { + min-height: 2.75rem; + appearance: none; +} + .service-media-field { min-width: 0; } @@ -2188,6 +2418,38 @@ code { color: var(--text-primary); } +.admin-token-grid { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + min-height: 3rem; + align-items: flex-start; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.045); + padding: 0.48rem; +} + +.admin-token { + min-height: 2.1rem; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.07); + color: var(--text-secondary); + padding: 0 0.8rem; + font-size: 0.76rem; + font-weight: 780; +} + +.admin-token:hover { + background: rgba(255, 255, 255, 0.11); + color: var(--text-primary); +} + +.admin-token[data-active="true"] { + background: rgba(247, 248, 244, 0.96); + color: rgb(var(--nodedc-on-accent-rgb)); +} + .access-layout { display: grid; grid-template-columns: minmax(0, 1fr) 21rem; @@ -2276,12 +2538,26 @@ code { overflow: hidden; } +.invites-layout--catalog { + grid-template-columns: 1fr; +} + .invite-form { display: grid; align-content: start; gap: 0.75rem; } +.invite-form--compact { + padding: 1rem; +} + +.invite-form__fields { + display: grid; + grid-template-columns: minmax(0, 1fr) 12rem; + gap: 0.65rem; +} + .invite-form input, .invite-form select { min-height: 2.7rem; @@ -2292,6 +2568,33 @@ code { padding: 0 0.8rem; } +.invite-form select { + appearance: none; +} + +.invite-link-cell { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.45rem; +} + +.invite-link-cell code { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-helper-note { + max-width: 38rem; + margin: 0.22rem 0 0; + color: var(--text-muted); + font-size: 0.73rem; + line-height: 1.35; +} + .company-panel { max-width: 42rem; } diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 139f9d1..d54f6fa 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -14,8 +14,10 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } import { CSS } from "@dnd-kit/utilities"; import { Building2, + CalendarDays, ChevronDown, ClipboardList, + Copy, DatabaseZap, Edit3, Globe2, @@ -38,10 +40,18 @@ import { X, } from "lucide-react"; import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types"; -import type { Invite } from "../../entities/invite/types"; +import type { Client, ClientStatus, ClientType } from "../../entities/client/types"; +import type { Invite, InviteStatus } from "../../entities/invite/types"; import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types"; -import type { SyncStatus } from "../../entities/sync/types"; -import type { ClientMembershipRole } from "../../entities/user/types"; +import type { SyncState, SyncStatus } from "../../entities/sync/types"; +import type { + ClientGroup, + ClientMembership, + ClientMembershipRole, + ClientMembershipStatus, + LauncherUser, + LauncherUserStatus, +} from "../../entities/user/types"; import { buildAccessMatrix, getClient, @@ -58,7 +68,6 @@ import { formatDate, formatDateTime } from "../../shared/lib/format"; import { Button, IconButton } from "../../shared/ui/Button"; import { GlassSurface } from "../../shared/ui/Glass"; import { PortalDropdown } from "../../shared/ui/PortalDropdown"; -import { ClientStatusBadge, ServiceStatusBadge, SyncStatusBadge, UserStatusBadge } from "../../shared/ui/StatusBadge"; type AdminSection = | "overview" @@ -103,7 +112,14 @@ export function AdminOverlay({ onCreateDenyException, onRemoveException, onCreateInvite, + onUpdateInvite, onRetrySync, + onCreateClient, + onUpdateClient, + onUpdateUser, + onUpdateMembership, + onCreateGroup, + onUpdateGroup, onUpdateService, onReorderServices, onCreateService, @@ -117,7 +133,14 @@ export function AdminOverlay({ onCreateDenyException: (exception: Omit) => void; onRemoveException: (exceptionId: string) => void; onCreateInvite: (invite: Pick) => void; + onUpdateInvite: (inviteId: string, patch: Partial) => void; onRetrySync: (syncId: string) => void; + onCreateClient: () => void; + onUpdateClient: (clientId: string, patch: Partial) => void; + onUpdateUser: (userId: string, patch: Partial) => void; + onUpdateMembership: (membershipId: string, patch: Partial) => void; + onCreateGroup: (clientId: string) => void; + onUpdateGroup: (groupId: string, patch: Partial) => void; onUpdateService: (serviceId: string, patch: Partial) => void; onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; @@ -211,9 +234,21 @@ export function AdminOverlay({
{activeSection === "overview" ? : null} - {activeSection === "clients" && isRoot ? : null} - {activeSection === "users" ? : null} - {activeSection === "groups" ? : null} + {activeSection === "clients" && isRoot ? ( + + ) : null} + {activeSection === "users" ? ( + + ) : null} + {activeSection === "groups" ? ( + + ) : null} {activeSection === "services" && isRoot ? ( ) : null} {activeSection === "invites" ? ( - + ) : null} {activeSection === "sync" ? : null} {activeSection === "audit" && isRoot ? : null} @@ -290,128 +331,329 @@ function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clien ); } -function ClientsSection({ data }: { data: LauncherData }) { +function ClientsSection({ + data, + onCreateClient, + onUpdateClient, +}: { + data: LauncherData; + onCreateClient: () => void; + onUpdateClient: (clientId: string, patch: Partial) => void; +}) { + const [editingClientId, setEditingClientId] = useState(null); + const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null; + return ( - -
-

Клиенты

- -
- - - - - - - - - - - - - {data.clients.map((client) => ( - - - - - - - + <> + +
+

Клиенты

+ + + +
+
НазваниеТипСтатусУчастниковDemoКонтакт
- {client.name} - {client.legalName ?? "Частное лицо"} - {client.type === "company" ? "Компания" : "Частное лицо"} - - {data.memberships.filter((membership) => membership.clientId === client.id).length}{formatDate(client.demoEndsAt)}{client.contactEmail}
+ + + + + + + + + - ))} - -
НазваниеТипСтатусУчастниковDemoКонтакт
-
+ + + {data.clients.map((client) => ( + + + onUpdateClient(client.id, { name: event.target.value })} + aria-label={`Название клиента ${client.name}`} + /> + onUpdateClient(client.id, { legalName: event.target.value || null })} + aria-label={`Юридическое название ${client.name}`} + /> + + + + + + onUpdateClient(client.id, { status })} + /> + + {data.memberships.filter((membership) => membership.clientId === client.id).length} + + onUpdateClient(client.id, { demoEndsAt: value })} /> + + + onUpdateClient(client.id, { contactEmail: event.target.value || null })} + aria-label={`Контакт клиента ${client.name}`} + /> + + + setEditingClientId(client.id)} + > + + + + + ))} + + + + + {editingClient ? ( + setEditingClientId(null)} + onSave={(patch) => { + onUpdateClient(editingClient.id, patch); + setEditingClientId(null); + }} + /> + ) : null} + ); } -function UsersSection({ data, clientId, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) { +function UsersSection({ + data, + clientId, + isRoot, + onUpdateUser, + onUpdateMembership, +}: { + data: LauncherData; + clientId: string; + isRoot: boolean; + onUpdateUser: (userId: string, patch: Partial) => void; + onUpdateMembership: (membershipId: string, patch: Partial) => void; +}) { + const [editingMembershipId, setEditingMembershipId] = useState(null); const rows = isRoot ? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) })) : data.memberships .filter((membership) => membership.clientId === clientId) .map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) })); + const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null; return ( - -
-

Участники

- -
- - - - - {isRoot ? : null} - - - - - - - {rows.map(({ membership, user, client }) => ( - - - {isRoot ? : null} - - - + <> + +
+

Участники

+ + + +
+
ПользовательКлиентРольГруппыСтатус
- {user.name} - {user.email} - {client.name}{roleLabel(membership.role)}{data.groups.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id)).map((group) => group.name).join(", ") || "—"} - -
+ + + + {isRoot ? : null} + + + + + - ))} - -
ПользовательКлиентРольГруппыСтатусДоступ
-
+ + + {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} + + + + + {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)} + > + + + + + ))} + + + + + {editingRow ? ( + setEditingMembershipId(null)} + onSave={(userPatch, membershipPatch) => { + onUpdateUser(editingRow.user.id, userPatch); + onUpdateMembership(editingRow.membership.id, membershipPatch); + setEditingMembershipId(null); + }} + /> + ) : null} + ); } -function GroupsSection({ data, clientId }: { data: LauncherData; clientId: string }) { +function GroupsSection({ + data, + clientId, + onCreateGroup, + onUpdateGroup, +}: { + data: LauncherData; + clientId: string; + onCreateGroup: (clientId: string) => void; + onUpdateGroup: (groupId: string, patch: Partial) => void; +}) { + const [editingGroupId, setEditingGroupId] = useState(null); const groups = data.groups.filter((group) => group.clientId === clientId); + const editingGroup = groups.find((group) => group.id === editingGroupId) ?? null; return ( - -
-

Группы

- -
- - - - - - - - - - - {groups.map((group) => ( - - - - - + <> + +
+

Группы

+ onCreateGroup(clientId)}> + + +
+
НазваниеОписаниеУчастниковПодключённые сервисы
- {group.name} - {group.description}{group.memberIds.length}{data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length}
+ + + + + + + - ))} - -
НазваниеОписаниеУчастниковПодключённые сервисы
-
+ + + {groups.map((group) => ( + + + onUpdateGroup(group.id, { name: event.target.value })} + aria-label={`Название группы ${group.name}`} + /> + + + onUpdateGroup(group.id, { description: event.target.value || null })} + aria-label={`Описание группы ${group.name}`} + /> + + {group.memberIds.length} + {data.grants.filter((grant) => grant.targetType === "group" && grant.targetId === group.id).length} + + setEditingGroupId(group.id)} + > + + + + + ))} + + + + + {editingGroup ? ( + membership.clientId === clientId) + .map((membership) => getUser(data, membership.userId))} + onClose={() => setEditingGroupId(null)} + onSave={(patch) => { + onUpdateGroup(editingGroup.id, patch); + setEditingGroupId(null); + }} + /> + ) : null} + ); } @@ -422,6 +664,48 @@ const serviceStatusOptions: Array<{ value: ServiceStatus; label: string }> = [ { value: "disabled", label: "Отключён" }, ]; +type AdminStatusTone = "green" | "yellow" | "red" | "violet" | "muted"; +type AdminStatusOption = { value: T; label: string; tone: AdminStatusTone }; + +const clientStatusOptions: Array> = [ + { value: "active", label: "Активен", tone: "green" }, + { value: "demo", label: "Demo", tone: "yellow" }, + { value: "suspended", label: "Приостановлен", tone: "red" }, + { value: "expired", label: "Истёк", tone: "red" }, +]; + +const userStatusOptions: Array> = [ + { value: "active", label: "Активен", tone: "green" }, + { value: "invited", label: "Приглашён", tone: "yellow" }, + { value: "blocked", label: "Заблокирован", tone: "red" }, +]; + +const membershipStatusOptions: Array> = [ + { value: "active", label: "Включён", tone: "green" }, + { value: "disabled", label: "Отключён", tone: "red" }, +]; + +const inviteStatusOptions: Array> = [ + { value: "created", label: "Создан", tone: "muted" }, + { value: "sent", label: "Отправлен", tone: "green" }, + { value: "accepted", label: "Принят", tone: "green" }, + { value: "expired", label: "Истёк", tone: "red" }, + { value: "revoked", label: "Отозван", tone: "red" }, +]; + +const syncStatusOptions: Array> = [ + { value: "synced", label: "Синхронизировано", tone: "green" }, + { value: "pending", label: "В очереди", tone: "yellow" }, + { value: "error", label: "Ошибка", tone: "red" }, + { value: "disabled", label: "Отключено", tone: "muted" }, +]; + +const auditResultOptions: Array> = [ + { value: "success", label: "Успех", tone: "green" }, + { value: "warning", label: "Внимание", tone: "yellow" }, + { value: "error", label: "Ошибка", tone: "red" }, +]; + const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv"; function ServicesSection({ @@ -763,6 +1047,188 @@ function ServiceStatusDropdown({ ); } +function AdminStatusDropdown({ + value, + options, + label, + onChange, +}: { + value: T; + options: Array>; + label: string; + onChange: (value: T) => void; +}) { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState(); + const selectedOption = options.find((option) => option.value === value) ?? options[0]; + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + + if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-status-menu='true']"))) { + return; + } + + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function toggleOpen() { + const rect = triggerRef.current?.getBoundingClientRect(); + + if (rect) { + setMenuStyle({ + top: rect.bottom + 8, + left: rect.left, + width: Math.max(rect.width, 164), + }); + } + + setOpen((current) => !current); + } + + return ( +
+ + + +
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} + +function AdminStatusPill({ value, options }: { value: T; options: Array> }) { + const option = options.find((item) => item.value === value) ?? options[0]; + return ( + + {option.label} + + ); +} + +function DateField({ + value, + label, + onChange, +}: { + value: string | null; + label: string; + onChange: (value: string | null) => void; +}) { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState(); + const inputValue = toDateInputValue(value); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + + if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-date-popover='true']"))) { + return; + } + + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function toggleOpen() { + const rect = triggerRef.current?.getBoundingClientRect(); + + if (rect) { + setMenuStyle({ + top: rect.bottom + 8, + left: rect.left, + width: 248, + }); + } + + setOpen((current) => !current); + } + + return ( +
+ + +
+ onChange(fromDateInputValue(event.target.value))} + /> +
+ + +
+
+
+
+ ); +} + function ServiceContentModal({ service, onClose, @@ -950,6 +1416,259 @@ function ServiceContentModal({ ); } +function ClientEditorModal({ + client, + onClose, + onSave, +}: { + client: Client; + onClose: () => void; + onSave: (patch: Partial) => void; +}) { + const [draft, setDraft] = useState(client); + + useEffect(() => setDraft(client), [client]); + + function update(key: K, value: Client[K]) { + setDraft((current) => ({ ...current, [key]: value })); + } + + return ( +
+
+ +
+ + + + +
+ Статус + update("status", status)} /> +
+ + +
+ Демо до + update("demoEndsAt", value)} /> +
+
+ Договор с + update("contractStartsAt", value)} /> +
+
+ Договор до + update("contractEndsAt", value)} /> +
+
+ Оплачено до + update("paidUntil", value)} /> +
+