Standardize NodeDC UI components
This commit is contained in:
parent
c99c91c826
commit
69eb5260b0
|
|
@ -23,7 +23,7 @@
|
|||
"tbody rows",
|
||||
"inline editable cells",
|
||||
"StatusControl cells",
|
||||
"DateField cells",
|
||||
"NodeDcDateField cells",
|
||||
"CircleActionButton action cells"
|
||||
],
|
||||
"visualContract": {
|
||||
|
|
@ -36,8 +36,7 @@
|
|||
"Create action is a solid circular plus, not a wide text CTA.",
|
||||
"Inline editable cells use transparent input surfaces that brighten on hover/focus.",
|
||||
"Status cells use StatusControl.",
|
||||
"Date cells use CalendarPopover/DateField.",
|
||||
"Date cells use CalendarPopover through NodeDcDateField.",
|
||||
"Drag handles move full rows, not detached ghosts."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,18 @@
|
|||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/top-bar/TopBar.tsx"
|
||||
"file": "src/widgets/top-bar/TopBar.tsx",
|
||||
"functions": ["TopBar"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Select.tsx",
|
||||
"exports": ["NodeDcSelect"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"exports": ["NodeDcProfileMenu"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
|
|
@ -37,4 +48,3 @@
|
|||
"Launcher and Task Manager headers must remain visually identical where routes overlap."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
"kind": "component",
|
||||
"status": "stable-reference",
|
||||
"summary": "Date picker popover based on Task Manager DateDropdown and nodedc-calendar-shell styling.",
|
||||
"implementation": {
|
||||
"baseLibrary": "react-day-picker@9.5.0",
|
||||
"localePackage": "date-fns@4.1.0",
|
||||
"launcherComponent": "NodeDcDateField renders NodeDcDropdown + NodeDcCalendar; NodeDcCalendar delegates the month grid/range math to DayPicker instead of a hand-written grid.",
|
||||
"isolation": "Calendar logic and styling live in src/shared/nodedc-ui/Calendar.tsx and src/shared/nodedc-ui/calendar.css. Feature screens must consume NodeDcDateField or NodeDcCalendar directly instead of creating local date wrappers."
|
||||
},
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
|
|
@ -15,10 +21,28 @@
|
|||
"file": "plane-src/apps/web/styles/globals.css",
|
||||
"classes": ["nodedc-calendar-shell"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Calendar.tsx",
|
||||
"exports": [
|
||||
"NodeDcDateField",
|
||||
"NodeDcCalendar",
|
||||
"NodeDcDateFieldProps",
|
||||
"NodeDcCalendarProps",
|
||||
"NodeDcCalendarRangePreview",
|
||||
"NodeDcDateInput",
|
||||
"NodeDcWeekStartsOn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/calendar.css",
|
||||
"classes": ["nodedc-ui-calendar", "nodedc-ui-calendar-frame", "nodedc-ui-date-trigger"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
"functions": ["DateField"]
|
||||
"consumers": ["ClientsSection", "ClientEditorModal", "InvitesSection"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
|
|
@ -33,15 +57,22 @@
|
|||
"surface": "DropdownSurface",
|
||||
"calendarRadius": "1.1rem",
|
||||
"dayButtonRadius": "999px",
|
||||
"selectedRangeFill": "accentRgb with high contrast dark text",
|
||||
"selectedRangeFill": "continuous accent row fill under the day numbers; row breaks create rounded start/end caps",
|
||||
"todayMarker": "today is always a circular ring marker; inside an accent range the ring uses the dark on-accent color and the day cell remains range-filled, not solid-filled",
|
||||
"outsideDays": "muted"
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Default placement bottom-start.",
|
||||
"Render through portal.",
|
||||
"Support minDate, maxDate, clearable value, start-of-week preference.",
|
||||
"Close on select when closeOnSelect is true."
|
||||
"Support minDate, maxDate, clearable value, closeOnSelect, empty labels, popover width, and start-of-week preference.",
|
||||
"Close on select when closeOnSelect is true.",
|
||||
"When rangePreview or rangeStart/rangeEnd are present, render a continuous accent range preview while still selecting one field value.",
|
||||
"When only one date is present, render a single circular selected date.",
|
||||
"Today must not be solid-filled. It uses an inset circular ring both outside and inside a range; inside a range the row fill remains visible behind the ring."
|
||||
],
|
||||
"nextImplementationStep": "Replace launcher native input date fallback with this shared calendar component."
|
||||
"currentConsumers": [
|
||||
"Client demo/contract/paid dates",
|
||||
"Invite expiration dates",
|
||||
"Any future date field in admin modals and tables"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@
|
|||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/ui/PortalDropdown.tsx"
|
||||
"file": "src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"exports": ["NodeDcDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/styles/globals.css",
|
||||
"classes": ["portal-dropdown", "nodedc-dropdown-surface"]
|
||||
"classes": ["nodedc-ui-dropdown-surface", "nodedc-dropdown-surface", "nodedc-ui-option", "nodedc-ui-dropdown-search"]
|
||||
}
|
||||
],
|
||||
"visualContract": {
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
"behaviorContract": [
|
||||
"Render on fixed/portal layer when inside cards, sidebars, tables, sticky headers, or scroll containers.",
|
||||
"Close on outside pointer and Escape.",
|
||||
"Use Popper or equivalent fixed-position placement.",
|
||||
"Use Popper or equivalent fixed-position placement based on trigger bounds.",
|
||||
"Default selection placement is bottom-start.",
|
||||
"Default action placement for card quick actions is bottom-start or bottom-end depending on anchor edge."
|
||||
],
|
||||
|
|
@ -45,4 +46,3 @@
|
|||
"outline": "none"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@
|
|||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"exports": ["ActionDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"exports": ["NodeDcProfileMenu"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/widgets/top-bar/TopBar.tsx",
|
||||
"functions": ["TopBar profile trigger"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
|
|
@ -35,7 +45,7 @@
|
|||
"rules": [
|
||||
"Profile popover is an ActionDropdown menuContent variant, not a separate dropdown engine.",
|
||||
"Avatar trigger stays circular and borderless.",
|
||||
"Menu uses DropdownSurface/ActionDropdown stacking rules."
|
||||
"Menu uses DropdownSurface/ActionDropdown stacking rules.",
|
||||
"Launcher implementation must keep the profile trigger pill visually identical to Task Manager header."
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"id": "select-dropdown",
|
||||
"name": "NodeDcSelect",
|
||||
"kind": "component",
|
||||
"status": "draft-stable",
|
||||
"summary": "Shared select/dropdown control for statuses, roles, clients, profile switches, filters, and compact table cells.",
|
||||
"sourceRefs": [
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Select.tsx",
|
||||
"exports": ["NodeDcSelect", "NodeDcSelectOption"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_launcher",
|
||||
"file": "src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"exports": ["NodeDcDropdown"]
|
||||
},
|
||||
{
|
||||
"project": "nodedc_taskmanager",
|
||||
"file": "plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
|
||||
"exports": ["ActionDropdown"]
|
||||
}
|
||||
],
|
||||
"anatomy": [
|
||||
"trigger button or custom trigger render",
|
||||
"portal DropdownSurface",
|
||||
"optional search row",
|
||||
"option rows with optional icon, description, tone, and selected check"
|
||||
],
|
||||
"visualContract": {
|
||||
"triggerRadius": "999px for table/header/status controls, 1rem inside entity forms",
|
||||
"menuSurface": "DropdownSurface",
|
||||
"optionRadius": "0.92rem",
|
||||
"selectedFill": "white/10 or tone-specific transparent fill",
|
||||
"outline": "none"
|
||||
},
|
||||
"behaviorContract": [
|
||||
"Never use native select for NODE.DC runtime UI.",
|
||||
"Render options through portal/fixed dropdown layer.",
|
||||
"Close on option select, outside pointer, and Escape.",
|
||||
"Support custom trigger render for header pills and side-nav client switcher."
|
||||
],
|
||||
"currentConsumers": [
|
||||
"TopBar client switcher",
|
||||
"TopBar profile-role switcher",
|
||||
"AdminOverlay client switcher",
|
||||
"AdminOverlay client type selects",
|
||||
"AdminOverlay membership role selects",
|
||||
"AdminOverlay invite role selects"
|
||||
]
|
||||
}
|
||||
|
|
@ -47,8 +47,16 @@
|
|||
{
|
||||
"id": "dropdown-surface",
|
||||
"spec": "components/dropdown-surface.json",
|
||||
"status": "stable-reference",
|
||||
"primarySource": "nodedc_taskmanager"
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "select-dropdown",
|
||||
"spec": "components/select-dropdown.json",
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "action-dropdown",
|
||||
|
|
@ -65,14 +73,16 @@
|
|||
{
|
||||
"id": "calendar-popover",
|
||||
"spec": "components/calendar-popover.json",
|
||||
"status": "stable-reference",
|
||||
"primarySource": "nodedc_taskmanager"
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "profile-menu",
|
||||
"spec": "components/profile-menu.json",
|
||||
"status": "stable-reference",
|
||||
"primarySource": "nodedc_taskmanager"
|
||||
"status": "draft-stable",
|
||||
"primarySource": "nodedc_launcher",
|
||||
"taskManagerReference": true
|
||||
},
|
||||
{
|
||||
"id": "admin-side-nav",
|
||||
|
|
@ -125,4 +135,3 @@
|
|||
"globalRules": "rules/ui-rules.json",
|
||||
"sourceMap": "sources/source-map.json"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{
|
||||
"id": "no-outlines",
|
||||
"severity": "error",
|
||||
"text": "Visible browser outlines and hard colored outlines are forbidden. Use surface hover/focus states."
|
||||
"text": "Visible outlines, stroke borders, focus rings, and hard colored contours are forbidden on runtime UI. Use only fill, opacity, blur, and surface hover/focus states."
|
||||
},
|
||||
{
|
||||
"id": "circle-actions",
|
||||
|
|
@ -48,4 +48,3 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@
|
|||
"primitiveUi": [
|
||||
"src/shared/ui/Button.tsx",
|
||||
"src/shared/ui/Glass.tsx",
|
||||
"src/shared/ui/PortalDropdown.tsx"
|
||||
"src/shared/ui/PortalDropdown.tsx",
|
||||
"src/shared/nodedc-ui/Dropdown.tsx",
|
||||
"src/shared/nodedc-ui/Select.tsx",
|
||||
"src/shared/nodedc-ui/Calendar.tsx",
|
||||
"src/shared/nodedc-ui/ProfileMenu.tsx",
|
||||
"src/shared/nodedc-ui/index.ts"
|
||||
],
|
||||
"storage": [
|
||||
"src/shared/api/storageApi.ts"
|
||||
|
|
@ -63,17 +68,22 @@
|
|||
"crossProjectMapping": [
|
||||
{
|
||||
"component": "dropdown-surface",
|
||||
"launcher": ["portal-dropdown", "nodedc-dropdown-surface"],
|
||||
"launcher": ["NodeDcDropdown", "nodedc-ui-dropdown-surface", "nodedc-dropdown-surface"],
|
||||
"taskManager": ["nodedc-dropdown-surface", "nodedc-dropdown-option"]
|
||||
},
|
||||
{
|
||||
"component": "select-dropdown",
|
||||
"launcher": ["NodeDcSelect", "TopBar selectors", "AdminOverlay role/type/client selectors"],
|
||||
"taskManager": ["ActionDropdown option menu pattern"]
|
||||
},
|
||||
{
|
||||
"component": "calendar-popover",
|
||||
"launcher": ["DateField", "nodedc-calendar-shell placeholder"],
|
||||
"launcher": ["NodeDcDateField", "NodeDcCalendar", "src/shared/nodedc-ui/calendar.css"],
|
||||
"taskManager": ["DateDropdown", "nodedc-calendar-shell"]
|
||||
},
|
||||
{
|
||||
"component": "profile-menu",
|
||||
"launcher": ["topbar profile cluster needs implementation"],
|
||||
"launcher": ["NodeDcProfileMenu", "TopBar profile cluster"],
|
||||
"taskManager": ["UserMenuRoot"]
|
||||
},
|
||||
{
|
||||
|
|
@ -83,4 +93,3 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.5.0",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -307,6 +309,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
|
|
@ -1574,6 +1582,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1908,6 +1932,27 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.5.0.tgz",
|
||||
"integrity": "sha512-WmJnPFVLnKh5Qscm7wavMNg86rqPverSWjx+zgK8/ZmGRSQ8c8OoqW10RI+AzAfT2atIxImpCUU2R9Z7Xb2SUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "^4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.5.0",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@
|
|||
"name": "DCTOUCH",
|
||||
"legalName": "ООО ДИСИТАЧ",
|
||||
"status": "active",
|
||||
"demoEndsAt": null,
|
||||
"demoEndsAt": "2026-05-29T21:00:00.000Z",
|
||||
"contactName": "Иван Петров",
|
||||
"contactEmail": "suppert@dctouch.ru",
|
||||
"notes": "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
|
||||
"createdAt": "2026-04-01T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T18:49:19.215Z"
|
||||
"updatedAt": "2026-05-01T21:04:34.270Z",
|
||||
"contractEndsAt": "2026-05-30T21:00:00.000Z",
|
||||
"contractStartsAt": "2026-04-25T21:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "client_roga_kopyta",
|
||||
|
|
@ -37,7 +39,9 @@
|
|||
"contactEmail": "ilya@example.ru",
|
||||
"notes": "Пример приостановленного частного клиента.",
|
||||
"createdAt": "2026-03-14T10:00:00Z",
|
||||
"updatedAt": "2026-05-01T09:00:00Z"
|
||||
"updatedAt": "2026-05-01T21:03:56.861Z",
|
||||
"contractStartsAt": "2026-04-30T21:00:00.000Z",
|
||||
"contractEndsAt": "2026-05-30T21:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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";
|
||||
|
|
@ -13,7 +12,7 @@ import {
|
|||
type LauncherData,
|
||||
} from "../shared/api/mockApi";
|
||||
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
|
||||
import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { AdminOverlay, type SetUserServiceAccessCommand } from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||
import { TopBar } from "../widgets/top-bar/TopBar";
|
||||
|
|
@ -111,45 +110,64 @@ export function LauncherApp() {
|
|||
});
|
||||
}
|
||||
|
||||
function handleCreateGrant(grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
|
||||
setData((current) => {
|
||||
const now = new Date().toISOString();
|
||||
const directGrant = current.grants.find(
|
||||
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId
|
||||
);
|
||||
const grantsWithoutDirect = current.grants.filter(
|
||||
(grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId)
|
||||
);
|
||||
const exceptionsWithoutDirect = current.exceptions.filter(
|
||||
(exception) => !(exception.serviceId === serviceId && exception.userId === userId)
|
||||
);
|
||||
|
||||
if (value === "unset") {
|
||||
return {
|
||||
...current,
|
||||
grants: grantsWithoutDirect,
|
||||
exceptions: exceptionsWithoutDirect,
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "deny") {
|
||||
return {
|
||||
...current,
|
||||
grants: grantsWithoutDirect,
|
||||
exceptions: [
|
||||
...exceptionsWithoutDirect,
|
||||
{
|
||||
id: `exception_mock_${Date.now()}`,
|
||||
serviceId,
|
||||
userId,
|
||||
type: "deny",
|
||||
reason: "Создано из матрицы доступа.",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
grants: [
|
||||
...current.grants,
|
||||
...grantsWithoutDirect,
|
||||
{
|
||||
...grant,
|
||||
id: `grant_mock_${Date.now()}`,
|
||||
id: directGrant?.id ?? `grant_mock_${Date.now()}`,
|
||||
serviceId,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
appRole: value,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: directGrant?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateDenyException(exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: [
|
||||
...current.exceptions.filter(
|
||||
(item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
|
||||
),
|
||||
{
|
||||
...exception,
|
||||
id: `exception_mock_${Date.now()}`,
|
||||
type: "deny",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRemoveException(exceptionId: string) {
|
||||
setData((current) => ({
|
||||
...current,
|
||||
exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
|
||||
}));
|
||||
exceptions: exceptionsWithoutDirect,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||
|
|
@ -422,9 +440,7 @@ export function LauncherApp() {
|
|||
me={me}
|
||||
activeClientId={resolvedClientId}
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onCreateGrant={handleCreateGrant}
|
||||
onCreateDenyException={handleCreateDenyException}
|
||||
onRemoveException={handleRemoveException}
|
||||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onRetrySync={handleRetrySync}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import { CalendarDays } from "lucide-react";
|
||||
import type { SVGProps } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { enUS, ru } from "date-fns/locale";
|
||||
import { DayPicker, type DateRange, type Matcher } from "react-day-picker";
|
||||
import { cn } from "../lib/cn";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
import "./calendar.css";
|
||||
|
||||
export type NodeDcDateInput = Date | string | null | undefined;
|
||||
export type NodeDcWeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface NodeDcCalendarRangePreview {
|
||||
from?: NodeDcDateInput;
|
||||
to?: NodeDcDateInput;
|
||||
}
|
||||
|
||||
export interface NodeDcCalendarProps {
|
||||
value: string | null;
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
rangePreview?: NodeDcCalendarRangePreview;
|
||||
minDate?: NodeDcDateInput;
|
||||
maxDate?: NodeDcDateInput;
|
||||
weekStartsOn?: NodeDcWeekStartsOn;
|
||||
clearable?: boolean;
|
||||
resetLabel?: string;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export interface NodeDcDateFieldProps {
|
||||
value: string | null;
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
rangePreview?: NodeDcCalendarRangePreview;
|
||||
minDate?: NodeDcDateInput;
|
||||
maxDate?: NodeDcDateInput;
|
||||
weekStartsOn?: NodeDcWeekStartsOn;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
emptyLabel?: string;
|
||||
clearable?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
resetLabel?: string;
|
||||
popoverWidth?: number;
|
||||
popoverMinWidth?: number;
|
||||
}
|
||||
|
||||
export function NodeDcDateField({
|
||||
value,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
rangePreview,
|
||||
minDate,
|
||||
maxDate,
|
||||
weekStartsOn,
|
||||
label,
|
||||
onChange,
|
||||
className,
|
||||
triggerClassName,
|
||||
placement = "bottom-start",
|
||||
emptyLabel = "Нет",
|
||||
clearable = true,
|
||||
closeOnSelect = true,
|
||||
resetLabel,
|
||||
popoverWidth = 328,
|
||||
popoverMinWidth = 328,
|
||||
}: NodeDcDateFieldProps) {
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={cn("nodedc-ui-date-field", className)}
|
||||
placement={placement}
|
||||
minWidth={popoverMinWidth}
|
||||
width={popoverWidth}
|
||||
surfaceClassName="nodedc-ui-calendar-popover"
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn("nodedc-ui-date-trigger", triggerClassName)}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<CalendarDays size={14} strokeWidth={1.75} />
|
||||
<span>{value ? formatDate(value) : emptyLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<NodeDcCalendar
|
||||
value={value}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
rangePreview={rangePreview}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
weekStartsOn={weekStartsOn}
|
||||
clearable={clearable}
|
||||
resetLabel={resetLabel}
|
||||
onChange={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
if (closeOnSelect) close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDcCalendar({
|
||||
value,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
rangePreview,
|
||||
minDate,
|
||||
maxDate,
|
||||
weekStartsOn = 0,
|
||||
clearable = true,
|
||||
resetLabel = "Сбросить дату",
|
||||
onChange,
|
||||
}: NodeDcCalendarProps) {
|
||||
const selectedDate = value ? parseDate(value) : null;
|
||||
const previewRange = useMemo(
|
||||
() => normalizeRange(rangePreview?.from ?? rangeStart, rangePreview?.to ?? rangeEnd),
|
||||
[rangeEnd, rangePreview?.from, rangePreview?.to, rangeStart]
|
||||
);
|
||||
const disabledDays = useMemo(() => buildDisabledDays(minDate, maxDate), [maxDate, minDate]);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startMonth = useMemo(() => new Date(currentYear - 30, 0, 1), [currentYear]);
|
||||
const endMonth = useMemo(() => new Date(currentYear + 30, 11, 31), [currentYear]);
|
||||
const locale = getCalendarLocale();
|
||||
|
||||
const sharedCalendarProps = {
|
||||
captionLayout: "dropdown" as const,
|
||||
className: "nodedc-ui-calendar nodedc-calendar-shell",
|
||||
components: { Chevron: CalendarChevron },
|
||||
defaultMonth: selectedDate ?? previewRange?.to ?? previewRange?.from ?? undefined,
|
||||
endMonth,
|
||||
fixedWeeks: true,
|
||||
initialFocus: true,
|
||||
locale,
|
||||
showOutsideDays: true,
|
||||
startMonth,
|
||||
weekStartsOn,
|
||||
disabled: disabledDays,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-ui-calendar-frame" data-nodedc-calendar="true">
|
||||
{previewRange ? (
|
||||
<DayPicker
|
||||
{...sharedCalendarProps}
|
||||
mode="range"
|
||||
selected={previewRange}
|
||||
onDayClick={(date, modifiers) => {
|
||||
if (modifiers.disabled) return;
|
||||
onChange(toLocalIso(date));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DayPicker
|
||||
{...sharedCalendarProps}
|
||||
mode="single"
|
||||
selected={selectedDate ?? undefined}
|
||||
onSelect={(date) => onChange(date ? toLocalIso(date) : null)}
|
||||
/>
|
||||
)}
|
||||
{clearable && value ? (
|
||||
<button className="nodedc-ui-calendar__reset" type="button" onClick={() => onChange(null)}>
|
||||
{resetLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarChevron({ className, orientation, ...props }: SVGProps<SVGSVGElement> & { orientation?: "up" | "down" | "left" | "right" }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"nodedc-ui-calendar__chevron",
|
||||
orientation === "right" && "nodedc-ui-calendar__chevron--right",
|
||||
orientation === "down" && "nodedc-ui-calendar__chevron--down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.55757 3.55708C9.80165 3.313 10.1983 3.313 10.4423 3.55708C10.6864 3.80116 10.6864 4.19777 10.4423 4.44185L6.88472 7.99946L10.4423 11.5571C10.6864 11.8012 10.6864 12.1978 10.4423 12.4418C10.1983 12.6859 9.80165 12.6859 9.55757 12.4418L5.55757 8.44185C5.31349 8.19777 5.31349 7.80116 5.55757 7.55708L9.55757 3.55708Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarLocale() {
|
||||
const localeCode =
|
||||
typeof document !== "undefined" && document.documentElement.lang
|
||||
? document.documentElement.lang
|
||||
: typeof navigator !== "undefined"
|
||||
? navigator.language
|
||||
: "ru-RU";
|
||||
|
||||
return localeCode.toLowerCase().startsWith("ru") ? ru : enUS;
|
||||
}
|
||||
|
||||
function normalizeRange(startValue?: NodeDcDateInput, endValue?: NodeDcDateInput): DateRange | undefined {
|
||||
if (!startValue || !endValue) return undefined;
|
||||
|
||||
const start = parseDate(startValue);
|
||||
const end = parseDate(endValue);
|
||||
|
||||
if (!start || !end) return undefined;
|
||||
|
||||
if (start <= end) return { from: start, to: end };
|
||||
return { from: end, to: start };
|
||||
}
|
||||
|
||||
function buildDisabledDays(minDate?: NodeDcDateInput, maxDate?: NodeDcDateInput): Matcher[] {
|
||||
const disabledDays: Matcher[] = [];
|
||||
const min = minDate ? parseDate(minDate) : null;
|
||||
const max = maxDate ? parseDate(maxDate) : null;
|
||||
|
||||
if (min) disabledDays.push({ before: min });
|
||||
if (max) disabledDays.push({ after: max });
|
||||
|
||||
return disabledDays;
|
||||
}
|
||||
|
||||
function parseDate(value: Date | string): Date | null {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) return date;
|
||||
|
||||
const fallbackDate = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||||
return Number.isNaN(fallbackDate.getTime()) ? null : fallbackDate;
|
||||
}
|
||||
|
||||
function toLocalDateInput(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalIso(date: Date): string {
|
||||
const day = toLocalDateInput(date);
|
||||
return new Date(`${day}T00:00:00`).toISOString();
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { useCallback, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type NodeDcDropdownPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
|
||||
interface NodeDcDropdownTriggerApi {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
setTriggerRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface NodeDcDropdownProps {
|
||||
trigger: (api: NodeDcDropdownTriggerApi) => ReactNode;
|
||||
children: ReactNode | ((api: { close: () => void }) => ReactNode);
|
||||
placement?: NodeDcDropdownPlacement;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
offset?: number;
|
||||
className?: string;
|
||||
surfaceClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NodeDcDropdown({
|
||||
trigger,
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
minWidth = 180,
|
||||
width,
|
||||
offset = 8,
|
||||
className,
|
||||
surfaceClassName,
|
||||
disabled = false,
|
||||
}: NodeDcDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [triggerEl, setTriggerEl] = useState<HTMLElement | null>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>();
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerEl) return;
|
||||
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const surfaceWidth = width ?? Math.max(rect.width, minWidth);
|
||||
const viewportPadding = 8;
|
||||
const alignedLeft = placement.endsWith("end") ? rect.right - surfaceWidth : rect.left;
|
||||
const left = Math.min(Math.max(viewportPadding, alignedLeft), window.innerWidth - surfaceWidth - viewportPadding);
|
||||
|
||||
if (placement.startsWith("top")) {
|
||||
setStyle({
|
||||
bottom: window.innerHeight - rect.top + offset,
|
||||
left,
|
||||
width: surfaceWidth,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStyle({
|
||||
top: rect.bottom + offset,
|
||||
left,
|
||||
width: surfaceWidth,
|
||||
});
|
||||
}, [minWidth, offset, placement, triggerEl, width]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
updatePosition();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (target && (triggerEl?.contains(target) || target.closest("[data-nodedc-dropdown-surface='true']"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [close, open, triggerEl, updatePosition]);
|
||||
|
||||
const triggerNode = trigger({
|
||||
open,
|
||||
toggle,
|
||||
close,
|
||||
setTriggerRef: setTriggerEl,
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={cn("nodedc-ui-dropdown", className)}>
|
||||
{triggerNode}
|
||||
{open && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className={cn("nodedc-ui-dropdown-surface nodedc-dropdown-surface", surfaceClassName)}
|
||||
data-nodedc-dropdown-surface="true"
|
||||
style={style}
|
||||
>
|
||||
{typeof children === "function" ? children({ close }) : children}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { LogOut, Settings } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { initials } from "../lib/format";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
|
||||
interface NodeDcProfileMenuUser {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface NodeDcProfileMenuProps {
|
||||
user: NodeDcProfileMenuUser;
|
||||
coverUrl?: string;
|
||||
trigger: (api: { open: boolean; toggle: () => void; setTriggerRef: (node: HTMLElement | null) => void }) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", trigger, className }: NodeDcProfileMenuProps) {
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={className}
|
||||
placement="bottom-end"
|
||||
width={296}
|
||||
surfaceClassName="nodedc-ui-profile-menu"
|
||||
trigger={({ open, toggle, setTriggerRef }) => trigger({ open, toggle, setTriggerRef })}
|
||||
>
|
||||
<div className="nodedc-ui-profile-card">
|
||||
<div className="nodedc-ui-profile-card__cover" style={{ backgroundImage: `url(${coverUrl})` }}>
|
||||
<Avatar user={user} className="nodedc-ui-profile-card__avatar" />
|
||||
<strong>{user.name}</strong>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<button className="nodedc-ui-profile-card__row" type="button">
|
||||
<Settings size={15} strokeWidth={1.7} />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
<button className="nodedc-ui-profile-card__row" type="button">
|
||||
<LogOut size={15} strokeWidth={1.7} />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ user, className }: { user: NodeDcProfileMenuUser; className?: string }) {
|
||||
if (user.avatarUrl) {
|
||||
return <img className={cn("nodedc-ui-avatar", className)} src={user.avatarUrl} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={cn("nodedc-ui-avatar", className)}>{initials(user.name)}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { NodeDcDropdown } from "./Dropdown";
|
||||
|
||||
export interface NodeDcSelectOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
tone?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NodeDcSelectTriggerApi<T extends string> {
|
||||
open: boolean;
|
||||
selectedOption: NodeDcSelectOption<T>;
|
||||
toggle: () => void;
|
||||
setTriggerRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface NodeDcSelectProps<T extends string> {
|
||||
value: T;
|
||||
options: Array<NodeDcSelectOption<T>>;
|
||||
label: string;
|
||||
onChange: (value: T, option: NodeDcSelectOption<T>) => void;
|
||||
trigger?: (api: NodeDcSelectTriggerApi<T>) => ReactNode;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
menuClassName?: string;
|
||||
optionClassName?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
minMenuWidth?: number;
|
||||
menuWidth?: number;
|
||||
disabled?: boolean;
|
||||
renderOptionLabel?: (option: NodeDcSelectOption<T>) => ReactNode;
|
||||
}
|
||||
|
||||
export function NodeDcSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
onChange,
|
||||
trigger,
|
||||
className,
|
||||
triggerClassName,
|
||||
menuClassName,
|
||||
optionClassName,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Поиск",
|
||||
placement = "bottom-start",
|
||||
minMenuWidth = 180,
|
||||
menuWidth,
|
||||
disabled = false,
|
||||
renderOptionLabel,
|
||||
}: NodeDcSelectProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleOptions = useMemo(() => {
|
||||
if (!normalizedQuery) return options;
|
||||
|
||||
return options.filter((option) => {
|
||||
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [normalizedQuery, options]);
|
||||
|
||||
return (
|
||||
<NodeDcDropdown
|
||||
className={className}
|
||||
placement={placement}
|
||||
minWidth={minMenuWidth}
|
||||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceClassName={cn("nodedc-ui-select-menu", menuClassName)}
|
||||
trigger={({ open, toggle, setTriggerRef }) =>
|
||||
trigger ? (
|
||||
trigger({ open, selectedOption, toggle, setTriggerRef })
|
||||
) : (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn("nodedc-ui-select-trigger", triggerClassName)}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
{selectedOption.icon ? <span className="nodedc-ui-select-trigger__icon">{selectedOption.icon}</span> : null}
|
||||
<span className="nodedc-ui-select-trigger__text">{selectedOption.label}</span>
|
||||
<ChevronDown className="nodedc-ui-select-trigger__chevron" size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{searchable ? (
|
||||
<label className="nodedc-ui-dropdown-search">
|
||||
<Search size={14} strokeWidth={1.8} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="nodedc-ui-option-list">
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={cn("nodedc-ui-option nodedc-dropdown-option", optionClassName)}
|
||||
data-selected={option.value === value}
|
||||
data-tone={option.tone}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (option.disabled) return;
|
||||
onChange(option.value, option);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{option.icon ? <span className="nodedc-ui-option__icon">{option.icon}</span> : null}
|
||||
<span className="nodedc-ui-option__body">
|
||||
<span className="nodedc-ui-option__label">{renderOptionLabel ? renderOptionLabel(option) : option.label}</span>
|
||||
{option.description ? <span className="nodedc-ui-option__description">{option.description}</span> : null}
|
||||
</span>
|
||||
{option.value === value ? <Check className="nodedc-ui-option__check" size={14} strokeWidth={1.8} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
.nodedc-ui-date-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-ui-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;
|
||||
}
|
||||
|
||||
.nodedc-ui-date-trigger:hover,
|
||||
.nodedc-ui-date-trigger:focus-visible,
|
||||
.nodedc-ui-date-trigger[aria-expanded="true"] {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar-popover {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar-frame {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
padding: 1.05rem 1.5rem 0.75rem;
|
||||
border-radius: 1.18rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar.rdp-root {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: calc(var(--rdp-cell-size) * 7);
|
||||
padding: 0;
|
||||
--rdp-accent-color: rgb(var(--nodedc-accent-rgb));
|
||||
--rdp-background-color: rgba(var(--nodedc-accent-rgb), 0.16);
|
||||
--rdp-dark-background-color: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white);
|
||||
--rdp-range-background-color: var(--rdp-accent-color);
|
||||
--rdp-selected-color: rgb(var(--nodedc-on-accent-rgb));
|
||||
--rdp-today-outline-color: rgb(var(--nodedc-on-accent-rgb));
|
||||
--rdp-outline: 0;
|
||||
--rdp-cell-size: 2.5rem;
|
||||
--rdp-caption-font-size: 1rem;
|
||||
--rdp-caption-navigation-size: 1.25rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-root,
|
||||
.nodedc-ui-calendar .rdp-months,
|
||||
.nodedc-ui-calendar .rdp-month {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-month_grid {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-month_caption {
|
||||
min-height: 2.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdowns {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown_root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
opacity: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-caption_label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.24rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.12rem;
|
||||
font-family: inherit;
|
||||
font-size: var(--rdp-caption-font-size);
|
||||
font-weight: 780;
|
||||
line-height: 1;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-dropdown_icon {
|
||||
margin: 0 0 0 0.18rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-nav {
|
||||
position: absolute;
|
||||
top: 0.42rem;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous,
|
||||
.nodedc-ui-calendar .rdp-button_next,
|
||||
.nodedc-ui-calendar .rdp-dropdown_root {
|
||||
border: 0;
|
||||
border-radius: 0.95rem;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous,
|
||||
.nodedc-ui-calendar .rdp-button_next {
|
||||
display: inline-flex;
|
||||
width: var(--rdp-caption-navigation-size);
|
||||
height: var(--rdp-caption-navigation-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-button_previous:hover,
|
||||
.nodedc-ui-calendar .rdp-button_next:hover,
|
||||
.nodedc-ui-calendar .rdp-button_previous:focus-visible,
|
||||
.nodedc-ui-calendar .rdp-button_next:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron--right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__chevron--down {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekdays,
|
||||
.nodedc-ui-calendar .rdp-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, var(--rdp-cell-size));
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekdays,
|
||||
.nodedc-ui-calendar .rdp-week,
|
||||
.nodedc-ui-calendar .rdp-weekday,
|
||||
.nodedc-ui-calendar .rdp-day {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week {
|
||||
height: var(--rdp-cell-size);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-weekday {
|
||||
height: var(--rdp-cell-size);
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 0.75em;
|
||||
font-weight: 720;
|
||||
line-height: var(--rdp-cell-size);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day_button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: var(--rdp-cell-size);
|
||||
max-width: var(--rdp-cell-size);
|
||||
height: var(--rdp-cell-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-outside:not(.rdp-selected) .rdp-day_button {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-disabled:not(.rdp-selected) .rdp-day_button {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day:not(.rdp-selected, .rdp-disabled) .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-day:not(.rdp-selected, .rdp-disabled) .rdp-day_button:focus-visible {
|
||||
background: var(--rdp-background-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button {
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--rdp-accent-color);
|
||||
color: var(--rdp-selected-color);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-selected .rdp-day_button:focus-visible {
|
||||
background: var(--rdp-accent-color);
|
||||
color: var(--rdp-selected-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today:not(.rdp-outside) .rdp-day_button {
|
||||
border-radius: 999px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today:not(.rdp-outside) .rdp-day_button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1.5px solid var(--rdp-today-outline-color);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-today.rdp-selected .rdp-day_button::after {
|
||||
border-color: var(--rdp-today-outline-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start,
|
||||
.nodedc-ui-calendar .rdp-range_middle,
|
||||
.nodedc-ui-calendar .rdp-range_end {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start::before,
|
||||
.nodedc-ui-calendar .rdp-range_middle::before,
|
||||
.nodedc-ui-calendar .rdp-range_end::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
background: var(--rdp-range-background-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start::before {
|
||||
border-radius: 999px 0 0 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_middle:first-child::before {
|
||||
border-radius: 999px 0 0 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_end:first-child::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_end::before {
|
||||
border-radius: 0 999px 999px 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_middle:last-child::before {
|
||||
border-radius: 0 999px 999px 0;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-week .rdp-range_start:last-child::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start.rdp-range_end::before {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-range_start .rdp-day_button,
|
||||
.nodedc-ui-calendar .rdp-range_middle .rdp-day_button,
|
||||
.nodedc-ui-calendar .rdp-range_end .rdp-day_button {
|
||||
background: transparent;
|
||||
color: var(--rdp-selected-color);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar .rdp-day.rdp-range_middle .rdp-day_button:hover,
|
||||
.nodedc-ui-calendar .rdp-day.rdp-range_middle .rdp-day_button:focus-visible {
|
||||
background: transparent;
|
||||
color: var(--rdp-selected-color);
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__reset {
|
||||
min-height: 2.1rem;
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.76rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-ui-calendar__reset:hover {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export { NodeDcDropdown } from "./Dropdown";
|
||||
export {
|
||||
NodeDcDateField,
|
||||
NodeDcCalendar,
|
||||
type NodeDcCalendarProps,
|
||||
type NodeDcDateFieldProps,
|
||||
type NodeDcCalendarRangePreview,
|
||||
type NodeDcDateInput,
|
||||
type NodeDcWeekStartsOn,
|
||||
} from "./Calendar";
|
||||
export { NodeDcProfileMenu } from "./ProfileMenu";
|
||||
export { NodeDcSelect, type NodeDcSelectOption } from "./Select";
|
||||
|
|
@ -263,6 +263,8 @@ code {
|
|||
border-radius: 999px;
|
||||
background: rgba(64, 64, 64, 0.48);
|
||||
padding: 0.32rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
|
||||
|
|
@ -322,6 +324,8 @@ code {
|
|||
}
|
||||
|
||||
.nodedc-toolbar-icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
|
@ -368,8 +372,8 @@ code {
|
|||
}
|
||||
|
||||
.nodedc-expanded-notification-button .nodedc-toolbar-icon-active-dot {
|
||||
height: auto;
|
||||
width: auto;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
|
|
@ -403,11 +407,12 @@ code {
|
|||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.42), transparent 24%),
|
||||
linear-gradient(135deg, rgba(195, 255, 102, 0.52), rgba(170, 120, 170, 0.72));
|
||||
radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.72), transparent 24%),
|
||||
linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139));
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.launcher-main {
|
||||
|
|
@ -1218,7 +1223,8 @@ code {
|
|||
width: var(--admin-control-ring);
|
||||
height: var(--admin-control-ring);
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none;
|
||||
|
|
@ -1226,7 +1232,6 @@ code {
|
|||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.07) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
@ -1240,11 +1245,27 @@ code {
|
|||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
outline: none;
|
||||
background: rgba(64, 64, 64, 0.48);
|
||||
padding: var(--admin-control-inset) calc(var(--admin-control-inset) + 1.9rem) var(--admin-control-inset)
|
||||
var(--admin-control-inset);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-client-select:hover,
|
||||
.admin-panel-client-select:focus,
|
||||
.admin-panel-client-select:focus-visible,
|
||||
.admin-panel-client-select[aria-expanded="true"] {
|
||||
border: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: rgba(74, 74, 74, 0.5);
|
||||
}
|
||||
|
||||
.admin-panel-client-select__icon,
|
||||
|
|
@ -1254,7 +1275,7 @@ code {
|
|||
height: var(--admin-control-ring);
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
|
@ -2070,72 +2091,6 @@ code {
|
|||
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;
|
||||
|
|
@ -2474,6 +2429,7 @@ code {
|
|||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.access-cell--allowed {
|
||||
|
|
@ -2495,17 +2451,56 @@ code {
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
.access-cell:hover,
|
||||
.access-cell[aria-expanded="true"] {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.access-cell span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.access-cell-menu {
|
||||
min-width: 10.75rem;
|
||||
}
|
||||
|
||||
.access-cell-menu .nodedc-ui-option[data-tone="green"][data-selected="true"] {
|
||||
background: rgba(181, 255, 90, 0.085);
|
||||
}
|
||||
|
||||
.access-cell-menu .nodedc-ui-option[data-tone="red"][data-selected="true"] {
|
||||
background: rgba(255, 120, 120, 0.08);
|
||||
}
|
||||
|
||||
.access-explanation {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.access-empty-state {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-height: 8rem;
|
||||
align-content: center;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.access-empty-state strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.access-empty-state span {
|
||||
max-width: 28rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.explanation-stack {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
|
|
@ -2616,25 +2611,305 @@ code {
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nodedc-ui-dropdown {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.portal-dropdown,
|
||||
.nodedc-dropdown-surface {
|
||||
.nodedc-dropdown-surface,
|
||||
.nodedc-ui-dropdown-surface {
|
||||
position: fixed;
|
||||
z-index: 420;
|
||||
padding: 0.65rem;
|
||||
border: 0;
|
||||
border-radius: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.026)),
|
||||
rgba(13, 13, 16, 0.9);
|
||||
box-shadow: 0 24px 88px rgba(0, 0, 0, 0.58);
|
||||
backdrop-filter: blur(26px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(1.12);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.02)),
|
||||
rgba(11, 11, 14, 0.94);
|
||||
box-shadow: 0 26px 92px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(var(--nodedc-dropdown-blur, 44px)) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(var(--nodedc-dropdown-blur, 44px)) saturate(1.12);
|
||||
}
|
||||
|
||||
.nodedc-dropdown-option {
|
||||
.nodedc-ui-select-trigger {
|
||||
display: inline-flex;
|
||||
min-height: 2.6rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.78rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.nodedc-ui-select-trigger:hover,
|
||||
.nodedc-ui-select-trigger:focus-visible,
|
||||
.nodedc-ui-select-trigger[aria-expanded="true"] {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-select-trigger__icon,
|
||||
.nodedc-ui-option__icon {
|
||||
display: grid;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-ui-select-trigger__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-ui-select-trigger__chevron {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.nodedc-ui-select-menu {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-dropdown-search {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 2.55rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--text-muted);
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-dropdown-search input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-dropdown-search input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.nodedc-ui-option-list {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.nodedc-dropdown-option,
|
||||
.nodedc-ui-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
min-height: 2.45rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 0.92rem;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
padding: 0.38rem 0.62rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodedc-ui-option:hover,
|
||||
.nodedc-ui-option:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-ui-option[data-selected="true"] {
|
||||
background: rgba(255, 255, 255, 0.105);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nodedc-ui-option[data-tone="green"][data-selected="true"] {
|
||||
background: rgba(181, 255, 90, 0.065);
|
||||
color: rgba(226, 255, 190, 0.96);
|
||||
}
|
||||
|
||||
.nodedc-ui-option[data-tone="yellow"][data-selected="true"] {
|
||||
background: rgba(246, 201, 95, 0.065);
|
||||
color: rgba(255, 232, 178, 0.96);
|
||||
}
|
||||
|
||||
.nodedc-ui-option[data-tone="violet"][data-selected="true"] {
|
||||
background: rgba(210, 197, 255, 0.06);
|
||||
color: rgba(232, 225, 255, 0.94);
|
||||
}
|
||||
|
||||
.nodedc-ui-option[data-tone="red"][data-selected="true"] {
|
||||
background: rgba(255, 120, 120, 0.06);
|
||||
color: rgba(255, 216, 216, 0.94);
|
||||
}
|
||||
|
||||
.nodedc-ui-option__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-option__label,
|
||||
.nodedc-ui-option__description {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-ui-option__description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-ui-option__check {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.admin-table-select-wrap,
|
||||
.admin-modal-select-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 1.95rem;
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.62rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.admin-table-select-trigger .nodedc-ui-select-trigger__text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-modal-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-menu {
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card {
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 8rem;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.08rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.9rem;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.38));
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nodedc-ui-avatar {
|
||||
display: grid;
|
||||
width: 3.35rem;
|
||||
height: 3.35rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background:
|
||||
radial-gradient(circle at 72% 20%, rgba(255, 255, 255, 0.72), transparent 23%),
|
||||
linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139));
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
object-fit: cover;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover > strong,
|
||||
.nodedc-ui-profile-card__cover > span:not(.nodedc-ui-avatar) {
|
||||
display: block;
|
||||
max-width: 86%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover > strong {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover > span:not(.nodedc-ui-avatar) {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__row {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.72rem;
|
||||
text-align: left;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__row:hover {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
|
|
@ -14,8 +14,6 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ClipboardList,
|
||||
Copy,
|
||||
DatabaseZap,
|
||||
|
|
@ -39,7 +37,7 @@ import {
|
|||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
import type { ServiceAppRole } from "../../entities/access/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";
|
||||
|
|
@ -65,9 +63,9 @@ import {
|
|||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||||
import { NodeDcDateField, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||||
import { Button, IconButton } from "../../shared/ui/Button";
|
||||
import { GlassSurface } from "../../shared/ui/Glass";
|
||||
import { PortalDropdown } from "../../shared/ui/PortalDropdown";
|
||||
|
||||
type AdminSection =
|
||||
| "overview"
|
||||
|
|
@ -81,6 +79,15 @@ type AdminSection =
|
|||
| "audit"
|
||||
| "company";
|
||||
|
||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||
|
||||
export interface SetUserServiceAccessCommand {
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
value: AccessAssignmentValue;
|
||||
}
|
||||
|
||||
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
||||
|
|
@ -108,9 +115,7 @@ export function AdminOverlay({
|
|||
me,
|
||||
activeClientId,
|
||||
onClose,
|
||||
onCreateGrant,
|
||||
onCreateDenyException,
|
||||
onRemoveException,
|
||||
onSetUserServiceAccess,
|
||||
onCreateInvite,
|
||||
onUpdateInvite,
|
||||
onRetrySync,
|
||||
|
|
@ -129,9 +134,7 @@ export function AdminOverlay({
|
|||
me: MeResponse;
|
||||
activeClientId: string;
|
||||
onClose: () => void;
|
||||
onCreateGrant: (grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) => void;
|
||||
onCreateDenyException: (exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) => void;
|
||||
onRemoveException: (exceptionId: string) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||
onRetrySync: (syncId: string) => void;
|
||||
|
|
@ -157,7 +160,8 @@ export function AdminOverlay({
|
|||
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
|
||||
const selectedAccessCell =
|
||||
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
|
||||
accessMatrix.cells[0];
|
||||
accessMatrix.cells[0] ??
|
||||
null;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -182,22 +186,33 @@ export function AdminOverlay({
|
|||
</div>
|
||||
|
||||
{isRoot ? (
|
||||
<label className="admin-panel-client-select">
|
||||
<NodeDcSelect
|
||||
value={selectedClientId}
|
||||
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
|
||||
label="Выбрать клиента"
|
||||
searchable
|
||||
minMenuWidth={292}
|
||||
onChange={(clientId) => {
|
||||
setSelectedClientId(clientId);
|
||||
setSelectedCell(null);
|
||||
}}
|
||||
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="admin-panel-client-select"
|
||||
type="button"
|
||||
aria-label="Выбрать клиента"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="admin-panel-client-select__icon">
|
||||
<Building2 size={16} />
|
||||
</span>
|
||||
<span className="admin-panel-client-select__name">{currentClient.name}</span>
|
||||
<span className="admin-panel-client-select__chevron">
|
||||
<ChevronDown size={14} strokeWidth={1.75} />
|
||||
</span>
|
||||
<select value={selectedClientId} onChange={(event) => setSelectedClientId(event.target.value)}>
|
||||
{data.clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="admin-panel-client-select__name">{selectedOption?.label ?? currentClient.name}</span>
|
||||
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-panel-client-select">
|
||||
<span className="admin-panel-client-select__icon">
|
||||
|
|
@ -264,9 +279,7 @@ export function AdminOverlay({
|
|||
matrix={accessMatrix}
|
||||
selectedCell={selectedAccessCell}
|
||||
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
|
||||
onCreateGrant={onCreateGrant}
|
||||
onCreateDenyException={onCreateDenyException}
|
||||
onRemoveException={onRemoveException}
|
||||
onSetUserServiceAccess={onSetUserServiceAccess}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "invites" ? (
|
||||
|
|
@ -382,15 +395,15 @@ function ClientsSection({
|
|||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="admin-table-input admin-table-input--select"
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={client.type}
|
||||
onChange={(event) => onUpdateClient(client.id, { type: event.target.value as ClientType })}
|
||||
aria-label={`Тип клиента ${client.name}`}
|
||||
>
|
||||
<option value="company">Компания</option>
|
||||
<option value="person">Частное лицо</option>
|
||||
</select>
|
||||
options={clientTypeOptions}
|
||||
label={`Тип клиента ${client.name}`}
|
||||
minMenuWidth={156}
|
||||
onChange={(type) => onUpdateClient(client.id, { type })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
|
|
@ -402,7 +415,11 @@ function ClientsSection({
|
|||
</td>
|
||||
<td>{data.memberships.filter((membership) => membership.clientId === client.id).length}</td>
|
||||
<td>
|
||||
<DateField value={client.demoEndsAt ?? null} label={`Demo до ${client.name}`} onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })} />
|
||||
<NodeDcDateField
|
||||
value={client.demoEndsAt ?? null}
|
||||
label={`Demo до ${client.name}`}
|
||||
onChange={(value) => onUpdateClient(client.id, { demoEndsAt: value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
|
|
@ -503,16 +520,15 @@ function UsersSection({
|
|||
</td>
|
||||
{isRoot ? <td>{client.name}</td> : null}
|
||||
<td>
|
||||
<select
|
||||
className="admin-table-input admin-table-input--select"
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={membership.role}
|
||||
onChange={(event) => onUpdateMembership(membership.id, { role: event.target.value as ClientMembershipRole })}
|
||||
aria-label={`Роль ${user.name}`}
|
||||
>
|
||||
<option value="client_owner">Owner</option>
|
||||
<option value="client_admin">Admin</option>
|
||||
<option value="member">Member</option>
|
||||
</select>
|
||||
options={membershipRoleOptions}
|
||||
label={`Роль ${user.name}`}
|
||||
minMenuWidth={198}
|
||||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{data.groups
|
||||
|
|
@ -657,16 +673,32 @@ function GroupsSection({
|
|||
);
|
||||
}
|
||||
|
||||
const serviceStatusOptions: Array<{ value: ServiceStatus; label: string }> = [
|
||||
{ value: "active", label: "Активен" },
|
||||
{ value: "maintenance", label: "Техработы" },
|
||||
{ value: "hidden", label: "Скрыт" },
|
||||
{ value: "disabled", label: "Отключён" },
|
||||
];
|
||||
|
||||
type AdminStatusTone = "green" | "yellow" | "red" | "violet" | "muted";
|
||||
type AdminStatusOption<T extends string> = { value: T; label: string; tone: AdminStatusTone };
|
||||
|
||||
const serviceStatusOptions: Array<AdminStatusOption<ServiceStatus>> = [
|
||||
{ value: "active", label: "Активен", tone: "green" },
|
||||
{ value: "maintenance", label: "Техработы", tone: "yellow" },
|
||||
{ value: "hidden", label: "Скрыт", tone: "violet" },
|
||||
{ value: "disabled", label: "Отключён", tone: "red" },
|
||||
];
|
||||
|
||||
const clientTypeOptions: Array<NodeDcSelectOption<ClientType>> = [
|
||||
{ value: "company", label: "Компания" },
|
||||
{ value: "person", label: "Частное лицо" },
|
||||
];
|
||||
|
||||
const membershipRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
|
||||
{ value: "client_owner", label: "Owner", description: "Владелец клиента" },
|
||||
{ value: "client_admin", label: "Admin", description: "Администратор клиента" },
|
||||
{ value: "member", label: "Member", description: "Пользователь" },
|
||||
];
|
||||
|
||||
const inviteRoleOptions: Array<NodeDcSelectOption<ClientMembershipRole>> = [
|
||||
{ value: "member", label: "Member" },
|
||||
{ value: "client_admin", label: "Client Admin" },
|
||||
];
|
||||
|
||||
const clientStatusOptions: Array<AdminStatusOption<ClientStatus>> = [
|
||||
{ value: "active", label: "Активен", tone: "green" },
|
||||
{ value: "demo", label: "Demo", tone: "yellow" },
|
||||
|
|
@ -706,6 +738,14 @@ const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error
|
|||
{ value: "error", label: "Ошибка", tone: "red" },
|
||||
];
|
||||
|
||||
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
|
||||
{ value: "unset", label: "—", description: "Не назначен" },
|
||||
{ value: "viewer", label: "viewer", description: "Просмотр", tone: "green" },
|
||||
{ value: "member", label: "member", description: "Участник", tone: "green" },
|
||||
{ value: "admin", label: "admin", description: "Администратор", tone: "green" },
|
||||
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
|
||||
];
|
||||
|
||||
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
||||
|
||||
function ServicesSection({
|
||||
|
|
@ -965,76 +1005,41 @@ function ServiceStatusDropdown({
|
|||
label: string;
|
||||
onChange: (status: ServiceStatus) => void;
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
|
||||
const selectedOption = serviceStatusOptions.find((option) => option.value === value) ?? serviceStatusOptions[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-service-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, 156),
|
||||
});
|
||||
}
|
||||
|
||||
setOpen((current) => !current);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-status-dropdown">
|
||||
<NodeDcDropdown
|
||||
className="service-status-dropdown"
|
||||
minWidth={156}
|
||||
surfaceClassName="service-status-menu"
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
ref={setTriggerRef}
|
||||
className="service-status-trigger"
|
||||
data-status={value}
|
||||
data-tone={selectedOption.tone}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={toggleOpen}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{selectedOption.label}</span>
|
||||
</button>
|
||||
|
||||
<PortalDropdown open={open} style={menuStyle}>
|
||||
<div className="service-status-menu" data-service-status-menu="true">
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="admin-status-menu__list">
|
||||
{serviceStatusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className="service-status-menu__option"
|
||||
className="service-status-menu__option nodedc-ui-option"
|
||||
data-selected={option.value === value}
|
||||
data-status={option.value}
|
||||
data-tone={option.tone}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span className="service-status-menu__mark" aria-hidden="true" />
|
||||
|
|
@ -1042,8 +1047,8 @@ function ServiceStatusDropdown({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PortalDropdown>
|
||||
</div>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1058,76 +1063,39 @@ function AdminStatusDropdown<T extends string>({
|
|||
label: string;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
|
||||
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 (
|
||||
<div className="admin-status-dropdown">
|
||||
<NodeDcDropdown
|
||||
className="admin-status-dropdown"
|
||||
minWidth={164}
|
||||
surfaceClassName="admin-status-menu"
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
ref={setTriggerRef}
|
||||
className="admin-status-trigger"
|
||||
data-tone={selectedOption.tone}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={toggleOpen}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{selectedOption.label}</span>
|
||||
</button>
|
||||
|
||||
<PortalDropdown open={open} style={menuStyle}>
|
||||
<div className="admin-status-menu" data-admin-status-menu="true">
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="admin-status-menu__list">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className="admin-status-menu__option"
|
||||
className="admin-status-menu__option nodedc-ui-option"
|
||||
data-selected={option.value === value}
|
||||
data-tone={option.tone}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span className="admin-status-menu__mark" aria-hidden="true" />
|
||||
|
|
@ -1135,8 +1103,8 @@ function AdminStatusDropdown<T extends string>({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PortalDropdown>
|
||||
</div>
|
||||
)}
|
||||
</NodeDcDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1149,86 +1117,6 @@ function AdminStatusPill<T extends string>({ value, options }: { value: T; optio
|
|||
);
|
||||
}
|
||||
|
||||
function DateField({
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>();
|
||||
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 (
|
||||
<div className="admin-date-field">
|
||||
<button ref={triggerRef} className="admin-date-trigger" type="button" aria-label={label} onClick={toggleOpen}>
|
||||
<CalendarDays size={14} />
|
||||
<span>{value ? formatDate(value) : "—"}</span>
|
||||
</button>
|
||||
<PortalDropdown open={open} style={menuStyle}>
|
||||
<div className="admin-date-popover nodedc-calendar-shell" data-admin-date-popover="true">
|
||||
<input
|
||||
type="date"
|
||||
value={inputValue}
|
||||
onChange={(event) => onChange(fromDateInputValue(event.target.value))}
|
||||
/>
|
||||
<div className="admin-date-popover__actions">
|
||||
<button type="button" onClick={() => onChange(null)}>
|
||||
Сбросить
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceContentModal({
|
||||
service,
|
||||
onClose,
|
||||
|
|
@ -1452,10 +1340,14 @@ function ClientEditorModal({
|
|||
</label>
|
||||
<label className="service-content-field">
|
||||
<span>Тип</span>
|
||||
<select value={draft.type} onChange={(event) => update("type", event.target.value as ClientType)}>
|
||||
<option value="company">Компания</option>
|
||||
<option value="person">Частное лицо</option>
|
||||
</select>
|
||||
<NodeDcSelect
|
||||
className="admin-modal-select-wrap"
|
||||
triggerClassName="admin-modal-select-trigger"
|
||||
value={draft.type}
|
||||
options={clientTypeOptions}
|
||||
label="Тип клиента"
|
||||
onChange={(type) => update("type", type)}
|
||||
/>
|
||||
</label>
|
||||
<div className="service-content-field">
|
||||
<span>Статус</span>
|
||||
|
|
@ -1471,19 +1363,31 @@ function ClientEditorModal({
|
|||
</label>
|
||||
<div className="service-content-field">
|
||||
<span>Демо до</span>
|
||||
<DateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
|
||||
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
|
||||
</div>
|
||||
<div className="service-content-field">
|
||||
<span>Договор с</span>
|
||||
<DateField value={draft.contractStartsAt ?? null} label="Договор с" onChange={(value) => update("contractStartsAt", value)} />
|
||||
<NodeDcDateField
|
||||
value={draft.contractStartsAt ?? null}
|
||||
rangeStart={draft.contractStartsAt ?? null}
|
||||
rangeEnd={draft.contractEndsAt ?? null}
|
||||
label="Договор с"
|
||||
onChange={(value) => update("contractStartsAt", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="service-content-field">
|
||||
<span>Договор до</span>
|
||||
<DateField value={draft.contractEndsAt ?? null} label="Договор до" onChange={(value) => update("contractEndsAt", value)} />
|
||||
<NodeDcDateField
|
||||
value={draft.contractEndsAt ?? null}
|
||||
rangeStart={draft.contractStartsAt ?? null}
|
||||
rangeEnd={draft.contractEndsAt ?? null}
|
||||
label="Договор до"
|
||||
onChange={(value) => update("contractEndsAt", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="service-content-field">
|
||||
<span>Оплачено до</span>
|
||||
<DateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
|
||||
<NodeDcDateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
|
||||
</div>
|
||||
<label className="service-content-field service-content-field--wide">
|
||||
<span>Заметки</span>
|
||||
|
|
@ -1552,11 +1456,14 @@ function UserEditorModal({
|
|||
</div>
|
||||
<label className="service-content-field">
|
||||
<span>Роль в клиенте</span>
|
||||
<select value={membershipDraft.role} onChange={(event) => updateMembership("role", event.target.value as ClientMembershipRole)}>
|
||||
<option value="client_owner">Client Owner</option>
|
||||
<option value="client_admin">Client Admin</option>
|
||||
<option value="member">Member</option>
|
||||
</select>
|
||||
<NodeDcSelect
|
||||
className="admin-modal-select-wrap"
|
||||
triggerClassName="admin-modal-select-trigger"
|
||||
value={membershipDraft.role}
|
||||
options={membershipRoleOptions}
|
||||
label="Роль в клиенте"
|
||||
onChange={(role) => updateMembership("role", role)}
|
||||
/>
|
||||
</label>
|
||||
<div className="service-content-field">
|
||||
<span>Доступ</span>
|
||||
|
|
@ -1761,30 +1668,51 @@ function AccessSection({
|
|||
matrix,
|
||||
selectedCell,
|
||||
onSelectCell,
|
||||
onCreateGrant,
|
||||
onCreateDenyException,
|
||||
onRemoveException,
|
||||
onSetUserServiceAccess,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||
selectedCell: AccessMatrixCell;
|
||||
selectedCell: AccessMatrixCell | null;
|
||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onCreateGrant: (grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) => void;
|
||||
onCreateDenyException: (exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) => void;
|
||||
onRemoveException: (exceptionId: string) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
}) {
|
||||
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
|
||||
|
||||
if (!hasMatrixData) {
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||
<span className="muted-text">Нет данных для матрицы</span>
|
||||
</div>
|
||||
<div className="access-empty-state">
|
||||
<strong>У клиента пока нет участников</strong>
|
||||
<span>Добавьте участника или инвайт, после этого здесь появятся ячейки доступа.</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="access-explanation access-explanation--empty">
|
||||
<p className="eyebrow">Explanation panel</p>
|
||||
<h3>Ячейка не выбрана</h3>
|
||||
<div className="explanation-stack">
|
||||
<InfoLine label="Итог" value="Нет данных" />
|
||||
<InfoLine label="Причина" value="У выбранного клиента нет участников в текущем наборе данных" />
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedUser = getUser(data, selectedCell.userId);
|
||||
const selectedService = getService(data, selectedCell.serviceId);
|
||||
const denyException = data.exceptions.find(
|
||||
(exception) => exception.serviceId === selectedCell.serviceId && exception.userId === selectedCell.userId && exception.type === "deny"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||
<span className="muted-text">Клик по ячейке открывает объяснение</span>
|
||||
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
||||
</div>
|
||||
<div className="matrix-scroll">
|
||||
<table>
|
||||
|
|
@ -1808,20 +1736,12 @@ function AccessSection({
|
|||
const active = selectedCell.userId === user.id && selectedCell.serviceId === service.id;
|
||||
return (
|
||||
<td key={service.id}>
|
||||
<button
|
||||
className={cn(
|
||||
"access-cell",
|
||||
cell.effectiveAccess.allowed && "access-cell--allowed",
|
||||
!cell.effectiveAccess.allowed && "access-cell--denied",
|
||||
cell.effectiveAccess.source === "exception" && "access-cell--exception",
|
||||
active && "access-cell--active"
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => onSelectCell(cell)}
|
||||
>
|
||||
<strong>{accessCellTitle(cell)}</strong>
|
||||
<span>{sourceLabel(cell.effectiveAccess.source)}</span>
|
||||
</button>
|
||||
<AccessCellControl
|
||||
cell={cell}
|
||||
active={active}
|
||||
onSelectCell={onSelectCell}
|
||||
onSetAccess={(value) => onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1844,48 +1764,57 @@ function AccessSection({
|
|||
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
|
||||
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
|
||||
</div>
|
||||
|
||||
<div className="access-actions">
|
||||
{!selectedCell.effectiveAccess.allowed ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<KeyRound size={16} />}
|
||||
onClick={() =>
|
||||
onCreateGrant({
|
||||
serviceId: selectedService.id,
|
||||
targetType: "user",
|
||||
targetId: selectedUser.id,
|
||||
appRole: "member",
|
||||
})
|
||||
}
|
||||
>
|
||||
Выдать пользователю
|
||||
</Button>
|
||||
) : null}
|
||||
{denyException ? (
|
||||
<Button variant="secondary" onClick={() => onRemoveException(denyException.id)}>
|
||||
Убрать deny
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
onCreateDenyException({
|
||||
serviceId: selectedService.id,
|
||||
userId: selectedUser.id,
|
||||
reason: "Создано из mock-матрицы доступа.",
|
||||
})
|
||||
}
|
||||
>
|
||||
Создать deny
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessCellControl({
|
||||
cell,
|
||||
active,
|
||||
onSelectCell,
|
||||
onSetAccess,
|
||||
}: {
|
||||
cell: AccessMatrixCell;
|
||||
active: boolean;
|
||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetAccess: (value: AccessAssignmentValue) => void;
|
||||
}) {
|
||||
const assignmentValue = accessAssignmentValue(cell);
|
||||
|
||||
return (
|
||||
<NodeDcSelect
|
||||
value={assignmentValue}
|
||||
options={accessAssignmentOptions}
|
||||
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
|
||||
minMenuWidth={172}
|
||||
menuClassName="access-cell-menu"
|
||||
onChange={(value) => onSetAccess(value)}
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn(
|
||||
"access-cell",
|
||||
cell.effectiveAccess.allowed && "access-cell--allowed",
|
||||
!cell.effectiveAccess.allowed && "access-cell--denied",
|
||||
cell.effectiveAccess.source === "exception" && "access-cell--exception",
|
||||
active && "access-cell--active"
|
||||
)}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
onSelectCell(cell);
|
||||
toggle();
|
||||
}}
|
||||
>
|
||||
<strong>{accessCellTitle(cell)}</strong>
|
||||
<span>{sourceLabel(cell.effectiveAccess.source)}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InvitesSection({
|
||||
data,
|
||||
clientId,
|
||||
|
|
@ -1932,10 +1861,14 @@ function InvitesSection({
|
|||
</div>
|
||||
<div className="invite-form__fields">
|
||||
<input value={email} onChange={(event) => setEmail(event.target.value)} placeholder="email@company.ru" />
|
||||
<select value={role} onChange={(event) => setRole(event.target.value as ClientMembershipRole)}>
|
||||
<option value="member">Member</option>
|
||||
<option value="client_admin">Client Admin</option>
|
||||
</select>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-modal-select-trigger"
|
||||
value={role}
|
||||
options={inviteRoleOptions}
|
||||
label="Роль инвайта"
|
||||
onChange={(nextRole) => setRole(nextRole)}
|
||||
/>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
|
|
@ -1965,15 +1898,15 @@ function InvitesSection({
|
|||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="admin-table-input admin-table-input--select"
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={invite.role}
|
||||
onChange={(event) => onUpdateInvite(invite.id, { role: event.target.value as ClientMembershipRole })}
|
||||
aria-label={`Роль инвайта ${invite.email}`}
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="client_admin">Client Admin</option>
|
||||
</select>
|
||||
options={inviteRoleOptions}
|
||||
label={`Роль инвайта ${invite.email}`}
|
||||
minMenuWidth={172}
|
||||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
|
|
@ -1997,7 +1930,7 @@ function InvitesSection({
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<DateField
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
|
|
@ -2152,21 +2085,6 @@ function InfoLine({ label, value }: { label: string; value: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null): string {
|
||||
if (!value) return "";
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 10);
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fromDateInputValue(value: string): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
return new Date(`${value}T00:00:00`).toISOString();
|
||||
}
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
root_admin: "Root Admin",
|
||||
|
|
@ -2199,6 +2117,14 @@ function accessCellTitle(cell: AccessMatrixCell): string {
|
|||
return cell.effectiveAccess.appRole ?? "allow";
|
||||
}
|
||||
|
||||
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
|
||||
if (cell.effectiveAccess.source === "exception" && !cell.effectiveAccess.allowed) return "deny";
|
||||
if (cell.effectiveAccess.source === "user" && cell.effectiveAccess.appRole) {
|
||||
return cell.effectiveAccess.appRole === "owner" ? "admin" : cell.effectiveAccess.appRole;
|
||||
}
|
||||
return "unset";
|
||||
}
|
||||
|
||||
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
||||
if (!source) return "—";
|
||||
const labels = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Inbox } from "lucide-react";
|
|||
import type { Client } from "../../entities/client/types";
|
||||
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
||||
|
||||
export function TopBar({
|
||||
me,
|
||||
|
|
@ -30,6 +31,16 @@ export function TopBar({
|
|||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
|
||||
const clientOptions = availableClients.map((client) => ({
|
||||
value: client.id,
|
||||
label: client.name,
|
||||
description: client.legalName ?? undefined,
|
||||
}));
|
||||
const profileSelectOptions = profileOptions.map((profile) => ({
|
||||
value: profile.userId,
|
||||
label: profile.label,
|
||||
description: profile.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<header className="nodedc-expanded-toolbar-shell">
|
||||
|
|
@ -42,32 +53,53 @@ export function TopBar({
|
|||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-center">
|
||||
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
|
||||
<NodeDcSelect
|
||||
value={activeClientId}
|
||||
options={clientOptions}
|
||||
label="Выбрать клиента"
|
||||
searchable
|
||||
minMenuWidth={248}
|
||||
onChange={(clientId) => onClientChange(clientId)}
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-workspace-button"
|
||||
title={activeClient?.name ?? "Клиент"}
|
||||
type="button"
|
||||
aria-label="Выбрать клиента"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
|
||||
{availableClients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
|
||||
<span>Витрина</span>
|
||||
</button>
|
||||
|
||||
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
|
||||
<span>{activeProfile?.label ?? me.user.name}</span>
|
||||
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
|
||||
{profileOptions.map((profile) => (
|
||||
<option key={profile.userId} value={profile.userId}>
|
||||
{profile.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NodeDcSelect
|
||||
value={activeProfileId}
|
||||
options={profileSelectOptions}
|
||||
label="Выбрать профиль доступа"
|
||||
minMenuWidth={236}
|
||||
onChange={(userId) => onProfileChange(userId)}
|
||||
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-nav-button nodedc-expanded-select-button"
|
||||
type="button"
|
||||
data-active="false"
|
||||
aria-label="Выбрать профиль доступа"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{selectedOption?.label ?? activeProfile?.label ?? me.user.name}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{me.permissions.canOpenAdmin ? (
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
|
||||
|
|
@ -78,19 +110,39 @@ export function TopBar({
|
|||
</div>
|
||||
|
||||
<div className="nodedc-expanded-toolbar-right">
|
||||
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||
<NodeDcProfileMenu
|
||||
user={me.user}
|
||||
trigger={({ open, toggle, setTriggerRef }) => (
|
||||
<div
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-user-group"
|
||||
title={`${me.user.name} · ${me.user.email}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Профиль пользователя"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-expanded-nav-button" data-active="false">
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
|
||||
</span>
|
||||
<span className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" data-active="false" aria-hidden="true">
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<Inbox size={20} strokeWidth={1.7} />
|
||||
</span>
|
||||
</button>
|
||||
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
|
||||
</span>
|
||||
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
||||
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue