diff --git a/dc-ui-guideline/components/admin-table.json b/dc-ui-guideline/components/admin-table.json index 92416d1..1920369 100644 --- a/dc-ui-guideline/components/admin-table.json +++ b/dc-ui-guideline/components/admin-table.json @@ -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." ] } - diff --git a/dc-ui-guideline/components/app-header.json b/dc-ui-guideline/components/app-header.json index 95a1201..8485c5b 100644 --- a/dc-ui-guideline/components/app-header.json +++ b/dc-ui-guideline/components/app-header.json @@ -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." ] } - diff --git a/dc-ui-guideline/components/calendar-popover.json b/dc-ui-guideline/components/calendar-popover.json index 2c5995f..3e0e776 100644 --- a/dc-ui-guideline/components/calendar-popover.json +++ b/dc-ui-guideline/components/calendar-popover.json @@ -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" + ] } - diff --git a/dc-ui-guideline/components/dropdown-surface.json b/dc-ui-guideline/components/dropdown-surface.json index 4f02c8c..1c8962f 100644 --- a/dc-ui-guideline/components/dropdown-surface.json +++ b/dc-ui-guideline/components/dropdown-surface.json @@ -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" } } - diff --git a/dc-ui-guideline/components/profile-menu.json b/dc-ui-guideline/components/profile-menu.json index f640439..57e6713 100644 --- a/dc-ui-guideline/components/profile-menu.json +++ b/dc-ui-guideline/components/profile-menu.json @@ -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." ] } - diff --git a/dc-ui-guideline/components/select-dropdown.json b/dc-ui-guideline/components/select-dropdown.json new file mode 100644 index 0000000..59e0f9b --- /dev/null +++ b/dc-ui-guideline/components/select-dropdown.json @@ -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" + ] +} diff --git a/dc-ui-guideline/registry.json b/dc-ui-guideline/registry.json index 40c4f53..669c0ad 100644 --- a/dc-ui-guideline/registry.json +++ b/dc-ui-guideline/registry.json @@ -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" } - diff --git a/dc-ui-guideline/rules/ui-rules.json b/dc-ui-guideline/rules/ui-rules.json index ee92355..fed2d60 100644 --- a/dc-ui-guideline/rules/ui-rules.json +++ b/dc-ui-guideline/rules/ui-rules.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 @@ } ] } - diff --git a/dc-ui-guideline/sources/source-map.json b/dc-ui-guideline/sources/source-map.json index d2ebd8b..394f99b 100644 --- a/dc-ui-guideline/sources/source-map.json +++ b/dc-ui-guideline/sources/source-map.json @@ -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 @@ } ] } - diff --git a/package-lock.json b/package-lock.json index a9a6984..188e0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 62fa990..c6e01f6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index a1f450f..6b3ee39 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -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": [ diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index b9241c7..f9087d7 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -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) { - setData((current) => ({ - ...current, - grants: [ - ...current.grants, - { - ...grant, - id: `grant_mock_${Date.now()}`, - status: "active", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ], - })); - } + 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) + ); - function handleCreateDenyException(exception: Omit) { - 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(), - }, - ], - })); - } + if (value === "unset") { + return { + ...current, + grants: grantsWithoutDirect, + exceptions: exceptionsWithoutDirect, + }; + } - function handleRemoveException(exceptionId: string) { - setData((current) => ({ - ...current, - exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId), - })); + 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: [ + ...grantsWithoutDirect, + { + id: directGrant?.id ?? `grant_mock_${Date.now()}`, + serviceId, + targetType: "user", + targetId: userId, + appRole: value, + status: "active", + createdAt: directGrant?.createdAt ?? now, + updatedAt: now, + }, + ], + exceptions: exceptionsWithoutDirect, + }; + }); } function handleCreateInvite(invite: Pick) { @@ -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} diff --git a/src/shared/nodedc-ui/Calendar.tsx b/src/shared/nodedc-ui/Calendar.tsx new file mode 100644 index 0000000..5a9431f --- /dev/null +++ b/src/shared/nodedc-ui/Calendar.tsx @@ -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 ( + ( + + )} + > + {({ close }) => ( + { + onChange(nextValue); + if (closeOnSelect) close(); + }} + /> + )} + + ); +} + +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 ( +
+ {previewRange ? ( + { + if (modifiers.disabled) return; + onChange(toLocalIso(date)); + }} + /> + ) : ( + onChange(date ? toLocalIso(date) : null)} + /> + )} + {clearable && value ? ( + + ) : null} +
+ ); +} + +function CalendarChevron({ className, orientation, ...props }: SVGProps & { orientation?: "up" | "down" | "left" | "right" }) { + return ( + + + + ); +} + +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(); +} diff --git a/src/shared/nodedc-ui/Dropdown.tsx b/src/shared/nodedc-ui/Dropdown.tsx new file mode 100644 index 0000000..da8475d --- /dev/null +++ b/src/shared/nodedc-ui/Dropdown.tsx @@ -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(null); + const [style, setStyle] = useState(); + + 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 ( + + {triggerNode} + {open && typeof document !== "undefined" + ? createPortal( +
+ {typeof children === "function" ? children({ close }) : children} +
, + document.body + ) + : null} +
+ ); +} diff --git a/src/shared/nodedc-ui/ProfileMenu.tsx b/src/shared/nodedc-ui/ProfileMenu.tsx new file mode 100644 index 0000000..76d95a5 --- /dev/null +++ b/src/shared/nodedc-ui/ProfileMenu.tsx @@ -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 ( + trigger({ open, toggle, setTriggerRef })} + > +
+
+ + {user.name} + {user.email} +
+ + +
+
+ ); +} + +function Avatar({ user, className }: { user: NodeDcProfileMenuUser; className?: string }) { + if (user.avatarUrl) { + return ; + } + + return {initials(user.name)}; +} diff --git a/src/shared/nodedc-ui/Select.tsx b/src/shared/nodedc-ui/Select.tsx new file mode 100644 index 0000000..d504132 --- /dev/null +++ b/src/shared/nodedc-ui/Select.tsx @@ -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 { + value: T; + label: string; + description?: string; + icon?: ReactNode; + tone?: string; + disabled?: boolean; +} + +interface NodeDcSelectTriggerApi { + open: boolean; + selectedOption: NodeDcSelectOption; + toggle: () => void; + setTriggerRef: (node: HTMLElement | null) => void; +} + +interface NodeDcSelectProps { + value: T; + options: Array>; + label: string; + onChange: (value: T, option: NodeDcSelectOption) => void; + trigger?: (api: NodeDcSelectTriggerApi) => 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) => ReactNode; +} + +export function NodeDcSelect({ + value, + options, + label, + onChange, + trigger, + className, + triggerClassName, + menuClassName, + optionClassName, + searchable = false, + searchPlaceholder = "Поиск", + placement = "bottom-start", + minMenuWidth = 180, + menuWidth, + disabled = false, + renderOptionLabel, +}: NodeDcSelectProps) { + 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 ( + + trigger ? ( + trigger({ open, selectedOption, toggle, setTriggerRef }) + ) : ( + + ) + } + > + {({ close }) => ( + <> + {searchable ? ( + + ) : null} + +
+ {visibleOptions.map((option) => ( + + ))} +
+ + )} +
+ ); +} diff --git a/src/shared/nodedc-ui/calendar.css b/src/shared/nodedc-ui/calendar.css new file mode 100644 index 0000000..a5bf4b3 --- /dev/null +++ b/src/shared/nodedc-ui/calendar.css @@ -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); +} diff --git a/src/shared/nodedc-ui/index.ts b/src/shared/nodedc-ui/index.ts new file mode 100644 index 0000000..ada7a98 --- /dev/null +++ b/src/shared/nodedc-ui/index.ts @@ -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"; diff --git a/src/styles/globals.css b/src/styles/globals.css index 1bf8441..a87c710 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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) { diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index d54f6fa..289be8b 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -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; +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: }, { id: "clients", label: "Клиенты", icon: }, @@ -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) => void; - onCreateDenyException: (exception: Omit) => void; - onRemoveException: (exceptionId: string) => void; + onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onCreateInvite: (invite: Pick) => void; onUpdateInvite: (inviteId: string, patch: Partial) => 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({ {isRoot ? ( - + ({ 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 }) => ( + + )} + /> ) : (
@@ -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({ /> - + options={clientTypeOptions} + label={`Тип клиента ${client.name}`} + minMenuWidth={156} + onChange={(type) => onUpdateClient(client.id, { type })} + /> {data.memberships.filter((membership) => membership.clientId === client.id).length} - onUpdateClient(client.id, { demoEndsAt: value })} /> + onUpdateClient(client.id, { demoEndsAt: value })} + /> {isRoot ? {client.name} : null} - + options={membershipRoleOptions} + label={`Роль ${user.name}`} + minMenuWidth={198} + onChange={(role) => onUpdateMembership(membership.id, { role })} + /> {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 = { value: T; label: string; tone: AdminStatusTone }; +const serviceStatusOptions: Array> = [ + { value: "active", label: "Активен", tone: "green" }, + { value: "maintenance", label: "Техработы", tone: "yellow" }, + { value: "hidden", label: "Скрыт", tone: "violet" }, + { value: "disabled", label: "Отключён", tone: "red" }, +]; + +const clientTypeOptions: Array> = [ + { value: "company", label: "Компания" }, + { value: "person", label: "Частное лицо" }, +]; + +const membershipRoleOptions: Array> = [ + { value: "client_owner", label: "Owner", description: "Владелец клиента" }, + { value: "client_admin", label: "Admin", description: "Администратор клиента" }, + { value: "member", label: "Member", description: "Пользователь" }, +]; + +const inviteRoleOptions: Array> = [ + { value: "member", label: "Member" }, + { value: "client_admin", label: "Client Admin" }, +]; + const clientStatusOptions: Array> = [ { value: "active", label: "Активен", tone: "green" }, { value: "demo", label: "Demo", tone: "yellow" }, @@ -706,6 +738,14 @@ const auditResultOptions: Array> = [ + { 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(null); - const [open, setOpen] = useState(false); - const [menuStyle, setMenuStyle] = useState(); 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 ( -
- - - -
+ ( + + )} + > + {({ close }) => ( +
{serviceStatusOptions.map((option) => ( ))}
- -
+ )} + ); } @@ -1058,76 +1063,39 @@ function AdminStatusDropdown({ label: string; onChange: (value: T) => void; }) { - const triggerRef = useRef(null); - const [open, setOpen] = useState(false); - const [menuStyle, setMenuStyle] = useState(); const selectedOption = options.find((option) => option.value === value) ?? options[0]; - useEffect(() => { - if (!open) return; - - const handlePointerDown = (event: PointerEvent) => { - const target = event.target as HTMLElement | null; - - if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-status-menu='true']"))) { - return; - } - - setOpen(false); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [open]); - - function toggleOpen() { - const rect = triggerRef.current?.getBoundingClientRect(); - - if (rect) { - setMenuStyle({ - top: rect.bottom + 8, - left: rect.left, - width: Math.max(rect.width, 164), - }); - } - - setOpen((current) => !current); - } - return ( -
- - - -
+ ( + + )} + > + {({ close }) => ( +
{options.map((option) => ( ))}
- -
+ )} + ); } @@ -1149,86 +1117,6 @@ function AdminStatusPill({ value, options }: { value: T; optio ); } -function DateField({ - value, - label, - onChange, -}: { - value: string | null; - label: string; - onChange: (value: string | null) => void; -}) { - const triggerRef = useRef(null); - const [open, setOpen] = useState(false); - const [menuStyle, setMenuStyle] = useState(); - const inputValue = toDateInputValue(value); - - useEffect(() => { - if (!open) return; - - const handlePointerDown = (event: PointerEvent) => { - const target = event.target as HTMLElement | null; - - if (target && (triggerRef.current?.contains(target) || target.closest("[data-admin-date-popover='true']"))) { - return; - } - - setOpen(false); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [open]); - - function toggleOpen() { - const rect = triggerRef.current?.getBoundingClientRect(); - - if (rect) { - setMenuStyle({ - top: rect.bottom + 8, - left: rect.left, - width: 248, - }); - } - - setOpen((current) => !current); - } - - return ( -
- - -
- onChange(fromDateInputValue(event.target.value))} - /> -
- - -
-
-
-
- ); -} - function ServiceContentModal({ service, onClose, @@ -1452,10 +1340,14 @@ function ClientEditorModal({
Статус @@ -1471,19 +1363,31 @@ function ClientEditorModal({
Демо до - update("demoEndsAt", value)} /> + update("demoEndsAt", value)} />
Договор с - update("contractStartsAt", value)} /> + update("contractStartsAt", value)} + />
Договор до - update("contractEndsAt", value)} /> + update("contractEndsAt", value)} + />
Оплачено до - update("paidUntil", value)} /> + update("paidUntil", value)} />
Доступ @@ -1761,30 +1668,51 @@ function AccessSection({ matrix, selectedCell, onSelectCell, - onCreateGrant, - onCreateDenyException, - onRemoveException, + onSetUserServiceAccess, }: { data: LauncherData; matrix: ReturnType; - selectedCell: AccessMatrixCell; + selectedCell: AccessMatrixCell | null; onSelectCell: (cell: AccessMatrixCell) => void; - onCreateGrant: (grant: Omit) => void; - onCreateDenyException: (exception: Omit) => void; - onRemoveException: (exceptionId: string) => void; + onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; }) { + const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null; + + if (!hasMatrixData) { + return ( +
+ +
+

Матрица доступа · {matrix.client.name}

+ Нет данных для матрицы +
+
+ У клиента пока нет участников + Добавьте участника или инвайт, после этого здесь появятся ячейки доступа. +
+
+ + +

Explanation panel

+

Ячейка не выбрана

+
+ + +
+
+
+ ); + } + 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 (

Матрица доступа · {matrix.client.name}

- Клик по ячейке открывает объяснение + Клик по ячейке открывает назначение
@@ -1808,20 +1736,12 @@ function AccessSection({ const active = selectedCell.userId === user.id && selectedCell.serviceId === service.id; return ( ); })} @@ -1844,48 +1764,57 @@ function AccessSection({ - -
- {!selectedCell.effectiveAccess.allowed ? ( - - ) : null} - {denyException ? ( - - ) : ( - - )} -
); } +function AccessCellControl({ + cell, + active, + onSelectCell, + onSetAccess, +}: { + cell: AccessMatrixCell; + active: boolean; + onSelectCell: (cell: AccessMatrixCell) => void; + onSetAccess: (value: AccessAssignmentValue) => void; +}) { + const assignmentValue = accessAssignmentValue(cell); + + return ( + onSetAccess(value)} + trigger={({ open, toggle, setTriggerRef }) => ( + + )} + /> + ); +} + function InvitesSection({ data, clientId, @@ -1932,10 +1861,14 @@ function InvitesSection({
setEmail(event.target.value)} placeholder="email@company.ru" /> - + setRole(nextRole)} + />
@@ -1965,15 +1898,15 @@ function InvitesSection({ />
- + onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })} + /> - + options={inviteRoleOptions} + label={`Роль инвайта ${invite.email}`} + minMenuWidth={172} + onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })} + /> - { @@ -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 = { 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 = { diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index 094735f..d14f137 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -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 (
@@ -42,32 +53,53 @@ export function TopBar({
- + onClientChange(clientId)} + trigger={({ open, toggle, setTriggerRef }) => ( + + )} + />
-
- - - -
+ ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }} + > + + Профиль + + + +
+ )} + />