Standardize NodeDC UI components

This commit is contained in:
DCCONSTRUCTIONS 2026-05-02 10:56:43 +03:00
parent c99c91c826
commit 69eb5260b0
22 changed files with 1955 additions and 550 deletions

View File

@ -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."
]
}

View File

@ -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."
]
}

View File

@ -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"
]
}

View File

@ -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"
}
}

View File

@ -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."
]
}

View File

@ -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"
]
}

View File

@ -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"
}

View File

@ -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 @@
}
]
}

View File

@ -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 @@
}
]
}

45
package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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": [

View File

@ -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}

View File

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

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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";

View File

@ -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) {

View File

@ -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 = {

View File

@ -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>