feat(web): собрать экраны из блоков ReUI PRO
CD / update-wiki (push) Successful in 6s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 52s
CD / quality (push) Failing after 1m5s
CD / publish (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 52s
CD / quality (push) Failing after 1m5s
CD / publish (push) Skipped
Подключить stats-12, card-12, settings-3 и empty-state-12 к оболочке, спискам, карточкам и настройкам, чтобы метрики и пустые состояния шли из одних блоков. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
73f5380768
commit
2418344551
58 files changed
+1846
-689
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
import { Frame } from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { CardItem } from "./card-item"
|
||||||
|
import { CARDS } from "./data"
|
||||||
|
|
||||||
|
export function CardGrid() {
|
||||||
|
return (
|
||||||
|
<Frame className="w-full">
|
||||||
|
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{CARDS.map((card) => (
|
||||||
|
<CardItem key={card.title} card={card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { FramePanel } from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { ICard } from "./data"
|
||||||
|
|
||||||
|
export function CardItem({ card }: { card: ICard }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={card.href}
|
||||||
|
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||||
|
>
|
||||||
|
<FramePanel className="space-y-7.5 p-3.5">
|
||||||
|
{card.icon}
|
||||||
|
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
</FramePanel>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
|
export interface ICard {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CARDS: ICard[] = [
|
||||||
|
{
|
||||||
|
title: "Valuation",
|
||||||
|
href: "#valuation",
|
||||||
|
icon: (
|
||||||
|
<ActivityIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ledger",
|
||||||
|
href: "#ledger",
|
||||||
|
icon: (
|
||||||
|
<BookOpenIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Funding",
|
||||||
|
href: "#funding",
|
||||||
|
icon: (
|
||||||
|
<ShieldIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cash Flow",
|
||||||
|
href: "#cash-flow",
|
||||||
|
icon: (
|
||||||
|
<CircleDollarSignIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Metrics",
|
||||||
|
href: "#metrics",
|
||||||
|
icon: (
|
||||||
|
<BarChart3Icon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Earnings",
|
||||||
|
href: "#earnings",
|
||||||
|
icon: (
|
||||||
|
<TrendingUp aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { CardGrid } from "./components/card-grid"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
|
||||||
|
<CardGrid />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type ToolbarOption<T extends string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_AUDIENCE_OPTIONS = [
|
||||||
|
{ value: "everyone", label: "Everyone" },
|
||||||
|
{ value: "ops-leads", label: "Ops leads" },
|
||||||
|
{ value: "finance-reviewers", label: "Finance reviewers" },
|
||||||
|
{ value: "client-owners", label: "Client owners" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_SCOPE_OPTIONS = [
|
||||||
|
{ value: "all-workspaces", label: "All workspaces" },
|
||||||
|
{ value: "harbor-field", label: "Harbor field" },
|
||||||
|
{ value: "market-lab", label: "Market lab" },
|
||||||
|
{ value: "support-desk", label: "Support desk" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_RANGE_OPTIONS = [
|
||||||
|
{ value: "last-30-days", label: "Last 30 days" },
|
||||||
|
{ value: "this-quarter", label: "This quarter" },
|
||||||
|
{ value: "previous-cycle", label: "Previous cycle" },
|
||||||
|
{ value: "custom-window", label: "Custom window" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_FORMAT_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "csv",
|
||||||
|
label: "CSV bundle",
|
||||||
|
description: "Spreadsheet-ready activity rows",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pdf",
|
||||||
|
label: "PDF brief",
|
||||||
|
description: "A concise review packet for stakeholders",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "schedule",
|
||||||
|
label: "Schedule delivery",
|
||||||
|
description: "Send this export every Friday morning",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { IconStack } from "@/components/reui/icon-stack"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
} from "@cfdm/ui/components/button-group"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@cfdm/ui/components/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@cfdm/ui/components/empty"
|
||||||
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
import {
|
||||||
|
EXPORT_AUDIENCE_OPTIONS,
|
||||||
|
EXPORT_FORMAT_OPTIONS,
|
||||||
|
EXPORT_RANGE_OPTIONS,
|
||||||
|
EXPORT_SCOPE_OPTIONS,
|
||||||
|
type ExportAudience,
|
||||||
|
type ExportFormat,
|
||||||
|
type ExportRange,
|
||||||
|
type ExportScope,
|
||||||
|
type ToolbarOption,
|
||||||
|
} from "./data"
|
||||||
|
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type ToolbarFilterProps<T extends string> = {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
options: readonly ToolbarOption<T>[]
|
||||||
|
onValueChange: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionLabel<T extends string>(
|
||||||
|
options: readonly ToolbarOption<T>[],
|
||||||
|
value: T
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarFilter<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onValueChange,
|
||||||
|
}: ToolbarFilterProps<T>) {
|
||||||
|
const selectedLabel = getOptionLabel(options, value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${label}: ${selectedLabel}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="start" className="min-w-44">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={value}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
if (nextValue !== null) {
|
||||||
|
onValueChange(nextValue as T)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
closeOnClick
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState() {
|
||||||
|
const [audience, setAudience] = useState<ExportAudience>(
|
||||||
|
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||||
|
)
|
||||||
|
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||||
|
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
|
||||||
|
|
||||||
|
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
|
||||||
|
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
|
||||||
|
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
|
||||||
|
|
||||||
|
const showExportToast = (format: ExportFormat = "csv") => {
|
||||||
|
const formatOption =
|
||||||
|
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
|
||||||
|
EXPORT_FORMAT_OPTIONS[0]
|
||||||
|
|
||||||
|
toast.message(`${formatOption.label} is ready to wire`, {
|
||||||
|
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||||
|
aria-labelledby="export-ledger-heading"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="flex min-w-0 flex-col gap-5">
|
||||||
|
{/* Title and Description */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{/* Title */}
|
||||||
|
<h2
|
||||||
|
id="export-ledger-heading"
|
||||||
|
className="text-2xl font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
Activity Exports
|
||||||
|
</h2>
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Download scoped activity packets for billing review, staffing
|
||||||
|
audits, and client handoffs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Audience"
|
||||||
|
value={audience}
|
||||||
|
options={EXPORT_AUDIENCE_OPTIONS}
|
||||||
|
onValueChange={setAudience}
|
||||||
|
/>
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Workspace"
|
||||||
|
value={scope}
|
||||||
|
options={EXPORT_SCOPE_OPTIONS}
|
||||||
|
onValueChange={setScope}
|
||||||
|
/>
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Date range"
|
||||||
|
value={range}
|
||||||
|
options={EXPORT_RANGE_OPTIONS}
|
||||||
|
onValueChange={setRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Action */}
|
||||||
|
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
onClick={() => showExportToast()}
|
||||||
|
>
|
||||||
|
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||||
|
<span>Execute</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ButtonGroupSeparator className="bg-primary/72" />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||||
|
aria-label="Open download options"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon aria-hidden="true" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||||
|
<FileDownIcon aria-hidden="true" />
|
||||||
|
CSV bundle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||||
|
<BookOpenIcon aria-hidden="true" />
|
||||||
|
PDF brief
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||||
|
<CalendarClockIcon aria-hidden="true" />
|
||||||
|
Schedule delivery
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||||
|
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||||
|
<EmptyHeader className="gap-5 text-center">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||||
|
</IconStack>
|
||||||
|
</EmptyMedia>
|
||||||
|
|
||||||
|
{/* Empty State Content */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||||
|
No exportable activity yet
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||||
|
Capture approved activity and this view will assemble your next
|
||||||
|
review packet.
|
||||||
|
</EmptyDescription>
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { EmptyState } from "./components/empty-state"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||||
|
aria-labelledby="page-heading"
|
||||||
|
>
|
||||||
|
<h1 id="page-heading" className="sr-only">
|
||||||
|
Activity exports empty state
|
||||||
|
</h1>
|
||||||
|
<EmptyState />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
|
||||||
|
import { ACCENT_COLORS } from "./data"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Color Picker ──
|
||||||
|
|
||||||
|
export function ColorPicker() {
|
||||||
|
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet className="gap-0">
|
||||||
|
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{ACCENT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
aria-label={color.name}
|
||||||
|
onClick={() => setSelected(color.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||||
|
selected === color.value &&
|
||||||
|
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
>
|
||||||
|
{selected === color.value ? (
|
||||||
|
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Preference = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
defaultChecked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccentColorOption = {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingBadgeVariant =
|
||||||
|
| "primary-light"
|
||||||
|
| "destructive-light"
|
||||||
|
| "info-light"
|
||||||
|
|
||||||
|
export type SettingFieldProps = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge?: { label: string; variant: SettingBadgeVariant }
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateFormatOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ──
|
||||||
|
|
||||||
|
export const DAYS: SelectOption[] = [
|
||||||
|
{ value: "monday", label: "Monday" },
|
||||||
|
{ value: "tuesday", label: "Tuesday" },
|
||||||
|
{ value: "wednesday", label: "Wednesday" },
|
||||||
|
{ value: "thursday", label: "Thursday" },
|
||||||
|
{ value: "friday", label: "Friday" },
|
||||||
|
{ value: "saturday", label: "Saturday" },
|
||||||
|
{ value: "sunday", label: "Sunday" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CURRENCIES: SelectOption[] = [
|
||||||
|
{ value: "usd", label: "USD · US Dollar" },
|
||||||
|
{ value: "eur", label: "EUR · Euro" },
|
||||||
|
{ value: "gbp", label: "GBP · British Pound" },
|
||||||
|
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const REGIONS: SelectOption[] = [
|
||||||
|
{ value: "us", label: "United States" },
|
||||||
|
{ value: "eu", label: "European Union" },
|
||||||
|
{ value: "uk", label: "United Kingdom" },
|
||||||
|
{ value: "jp", label: "Japan" },
|
||||||
|
{ value: "au", label: "Australia" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||||
|
{ name: "Slate", value: "#64748b" },
|
||||||
|
{ name: "Rose", value: "#f43f5e" },
|
||||||
|
{ name: "Orange", value: "#f97316" },
|
||||||
|
{ name: "Violet", value: "#8b5cf6" },
|
||||||
|
{ name: "Emerald", value: "#10b981" },
|
||||||
|
{ name: "Sky", value: "#0ea5e9" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PREFERENCES: Preference[] = [
|
||||||
|
{
|
||||||
|
id: "keyboard-shortcuts",
|
||||||
|
label: "Enable keyboard shortcuts.",
|
||||||
|
description: "Use shortcuts to speed up your workflow.",
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spellcheck",
|
||||||
|
label: "Auto spell-check.",
|
||||||
|
description: "Highlight spelling errors in text fields.",
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compact-mode",
|
||||||
|
label: "Compact Mode.",
|
||||||
|
description: "Reduce spacing for a denser interface layout.",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "animations",
|
||||||
|
label: "Reduce animations.",
|
||||||
|
description: "Minimize motion effects throughout the UI.",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||||
|
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||||
|
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||||
|
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@cfdm/ui/components/field"
|
||||||
|
import { Switch } from "@cfdm/ui/components/switch"
|
||||||
|
|
||||||
|
import { PREFERENCES } from "./data"
|
||||||
|
|
||||||
|
// ── Editor Preferences ──
|
||||||
|
|
||||||
|
export function EditorPreferences() {
|
||||||
|
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||||
|
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||||
|
{/* Description */}
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Configure editing behavior and interface density.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
{PREFERENCES.map((pref) => (
|
||||||
|
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||||
|
<Switch
|
||||||
|
id={`settings-3-${pref.id}`}
|
||||||
|
checked={values[pref.id]}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldContent className="gap-0.5">
|
||||||
|
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||||
|
{pref.label}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldDescription className="text-xs">
|
||||||
|
{pref.description}
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@cfdm/ui/components/field"
|
||||||
|
import { Input } from "@cfdm/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@cfdm/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@cfdm/ui/components/select"
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@cfdm/ui/components/toggle-group"
|
||||||
|
import { ColorPicker } from "./color-picker"
|
||||||
|
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||||
|
import { EditorPreferences } from "./editor-preferences"
|
||||||
|
import { SettingField } from "./setting-field"
|
||||||
|
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function GeneralSettings() {
|
||||||
|
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||||
|
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||||
|
const [region, setRegion] = useState("us")
|
||||||
|
const [currency, setCurrency] = useState("usd")
|
||||||
|
const handleStartOfWeekChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setStartOfWeek(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleRegionChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setRegion(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleCurrencyChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setCurrency(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className="w-full max-w-3xl">
|
||||||
|
<FrameHeader className="px-2! py-2.5!">
|
||||||
|
<FrameTitle>General Settings</FrameTitle>
|
||||||
|
<FrameDescription>Core app preferences.</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
{/* ── Project name ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Project name"
|
||||||
|
description="The display name for your project across the platform."
|
||||||
|
labelFor="settings-3-project-name"
|
||||||
|
>
|
||||||
|
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── API endpoint ── */}
|
||||||
|
<SettingField
|
||||||
|
title="API endpoint"
|
||||||
|
description="Set the base URL for your project API."
|
||||||
|
badge={{ label: "Required", variant: "destructive-light" }}
|
||||||
|
labelFor="settings-3-api-endpoint"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>https://</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-3-api-endpoint"
|
||||||
|
defaultValue="api.acme.io"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Start of week ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Start of week"
|
||||||
|
description="Choose which day marks the start of your week."
|
||||||
|
labelFor="settings-3-start-of-week"
|
||||||
|
>
|
||||||
|
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||||
|
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||||
|
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Allowed origins ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Allowed origins"
|
||||||
|
description="Define the trusted domains for CORS requests."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline">
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
Add origin
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline">
|
||||||
|
<UploadIcon aria-hidden="true" />
|
||||||
|
Import from file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Accent color ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Accent color"
|
||||||
|
description="Select a color to represent your brand."
|
||||||
|
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||||
|
>
|
||||||
|
<ColorPicker />
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Region & currency ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Region & currency"
|
||||||
|
description="Adjust your regional preferences and currency."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Regional and financial formatting preferences.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||||
|
<Select value={region} onValueChange={handleRegionChange}>
|
||||||
|
<SelectTrigger id="settings-3-region" className="w-full">
|
||||||
|
<GlobeIcon aria-hidden="true" />
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(REGIONS, region)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{REGIONS.map((region) => (
|
||||||
|
<SelectItem key={region.value} value={region.value}>
|
||||||
|
{region.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-3-currency">
|
||||||
|
Currency
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||||
|
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||||
|
<CircleDollarSignIcon aria-hidden="true" />
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(CURRENCIES, currency)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{CURRENCIES.map((currency) => (
|
||||||
|
<SelectItem
|
||||||
|
key={currency.value}
|
||||||
|
value={currency.value}
|
||||||
|
>
|
||||||
|
{currency.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Date display format ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Date display format"
|
||||||
|
description="Choose your preferred format for dates."
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
multiple={false}
|
||||||
|
value={dateFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.length > 0) setDateFormat(value)
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Date display format"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Editor preferences ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Editor preferences"
|
||||||
|
description="Adjust your editing environment and display options."
|
||||||
|
last
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<EditorPreferences />
|
||||||
|
</SettingField>
|
||||||
|
</FieldGroup>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||||
|
<Button variant="outline">Reset</Button>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</FrameFooter>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper functions ──
|
||||||
|
|
||||||
|
function getOptionLabel(
|
||||||
|
options: Array<{ value: string; label: string }>,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type ComponentProps, type ReactNode } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@cfdm/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingFieldProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge?: {
|
||||||
|
label: string
|
||||||
|
variant: ComponentProps<typeof Badge>["variant"]
|
||||||
|
}
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setting Field ──
|
||||||
|
|
||||||
|
export function SettingField({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
}: SettingFieldProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{badge ? (
|
||||||
|
<Badge variant={badge.variant} size="sm">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { GeneralSettings } from "./components/general-settings"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||||
|
<GeneralSettings />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactNode } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface CardData {
|
||||||
|
icon: ReactNode
|
||||||
|
iconBg: string
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
info: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ──
|
||||||
|
|
||||||
|
export const cards: CardData[] = [
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<Headphones aria-hidden="true" />
|
||||||
|
),
|
||||||
|
iconBg: "text-blue-600 dark:text-blue-400",
|
||||||
|
value: 320,
|
||||||
|
label: "Support Tickets",
|
||||||
|
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<CircleCheckIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
value: "98%",
|
||||||
|
label: "Resolved",
|
||||||
|
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<SmileIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
iconBg: "text-amber-600 dark:text-amber-400",
|
||||||
|
value: "4.8",
|
||||||
|
label: "Satisfaction Rate",
|
||||||
|
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||||
|
|
||||||
|
import { cards } from "./data"
|
||||||
|
|
||||||
|
export function Stats() {
|
||||||
|
return (
|
||||||
|
<div className="@container w-full grow">
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||||
|
{cards.map((card, i) => (
|
||||||
|
<Frame key={i}>
|
||||||
|
<FramePanel className="flex flex-col items-start gap-6">
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
|
||||||
|
card.iconBg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{card.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-foreground text-2xl leading-none font-bold">
|
||||||
|
{card.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">
|
||||||
|
{card.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card.info}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Stats } from "./components/stats"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||||
|
<Stats />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,15 +9,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
|
|
||||||
interface DomainsBulkToolbarProps {
|
interface DomainsBulkToolbarProps {
|
||||||
count: number
|
count: number
|
||||||
isPending?: boolean
|
isPending?: boolean
|
||||||
groupItems: { label: string; value: string }[]
|
groupItems: { label: string; value: string }[]
|
||||||
onAssignGroup: (groupId: number | null) => void
|
onAssignGroup: (groupId: number | null) => void
|
||||||
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
|
|
||||||
onAddTag: (tag: string) => void
|
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
|
|||||||
isPending = false,
|
isPending = false,
|
||||||
groupItems,
|
groupItems,
|
||||||
onAssignGroup,
|
onAssignGroup,
|
||||||
onSetEnvironment,
|
|
||||||
onAddTag,
|
|
||||||
onClear,
|
onClear,
|
||||||
}: DomainsBulkToolbarProps) {
|
}: DomainsBulkToolbarProps) {
|
||||||
const [groupValue, setGroupValue] = useState('none')
|
const [groupValue, setGroupValue] = useState('none')
|
||||||
const [envValue, setEnvValue] = useState('none')
|
|
||||||
const [tag, setTag] = useState('')
|
|
||||||
|
|
||||||
if (count === 0) return null
|
if (count === 0) return null
|
||||||
|
|
||||||
const envItems = [
|
|
||||||
{ label: 'Без env', value: 'none' },
|
|
||||||
{ label: 'prod', value: 'prod' },
|
|
||||||
{ label: 'staging', value: 'staging' },
|
|
||||||
{ label: 'dev', value: 'dev' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="mb-3 w-full">
|
<Frame dense spacing="sm" className="mb-3 w-full">
|
||||||
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
|
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
|
||||||
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
|
|||||||
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
|
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
В группу
|
Назначить группу
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
|
||||||
items={envItems}
|
|
||||||
value={envValue}
|
|
||||||
onValueChange={(v) => setEnvValue(v ?? 'none')}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32" size="sm">
|
|
||||||
<SelectValue placeholder="Env" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{envItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() =>
|
onClick={() => onAssignGroup(null)}
|
||||||
onSetEnvironment(
|
|
||||||
envValue === 'none'
|
|
||||||
? null
|
|
||||||
: (envValue as 'prod' | 'staging' | 'dev'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Env
|
Снять группу
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
className="h-8 w-32"
|
|
||||||
placeholder="тег"
|
|
||||||
value={tag}
|
|
||||||
onChange={(e) => setTag(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isPending || !tag.trim()}
|
|
||||||
onClick={() => {
|
|
||||||
onAddTag(tag.trim())
|
|
||||||
setTag('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ тег
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
import {
|
import {
|
||||||
BlocksIcon,
|
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
HeartPulseIcon,
|
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -21,6 +21,13 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from '@cfdm/ui/components/command'
|
} from '@cfdm/ui/components/command'
|
||||||
|
import {
|
||||||
|
domainsListQueryOptions,
|
||||||
|
groupsQueryOptions,
|
||||||
|
serviceGroupsQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
|
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
|
||||||
|
|
||||||
const SECTION_ITEMS = [
|
const SECTION_ITEMS = [
|
||||||
{
|
{
|
||||||
@@ -61,35 +68,22 @@ const SECTION_ITEMS = [
|
|||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const ACTION_ITEMS = [
|
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
|
||||||
{
|
|
||||||
to: '/settings/health',
|
|
||||||
label: 'Health-check',
|
|
||||||
keywords: 'health проверка пороги cron',
|
|
||||||
icon: HeartPulseIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/settings/integrations',
|
|
||||||
label: 'Интеграции',
|
|
||||||
keywords: 'integrations app switcher портал',
|
|
||||||
icon: BlocksIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/settings/appearance',
|
|
||||||
label: 'Внешний вид',
|
|
||||||
keywords: 'appearance тема быстрые действия',
|
|
||||||
icon: PaletteIcon,
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type PaletteTarget =
|
|
||||||
| (typeof SECTION_ITEMS)[number]['to']
|
|
||||||
| (typeof ACTION_ITEMS)[number]['to']
|
|
||||||
|
|
||||||
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
|
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
|
||||||
|
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
|
||||||
|
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
|
||||||
|
|
||||||
|
const services = useMemo(() => {
|
||||||
|
const data = servicesQuery.data
|
||||||
|
if (!data) return []
|
||||||
|
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
|
||||||
|
}, [servicesQuery.data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
@@ -99,8 +93,16 @@ export function CommandPalette() {
|
|||||||
setOpen((current) => !current)
|
setOpen((current) => !current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function go(to: PaletteTarget) {
|
function go(to: PaletteTarget) {
|
||||||
@@ -117,9 +119,38 @@ export function CommandPalette() {
|
|||||||
className="sm:max-w-md"
|
className="sm:max-w-md"
|
||||||
>
|
>
|
||||||
<Command key={open ? 'open' : 'closed'}>
|
<Command key={open ? 'open' : 'closed'}>
|
||||||
<CommandInput autoFocus placeholder="Перейти к разделу или действию…" />
|
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||||
|
<CommandGroup heading="Действия">
|
||||||
|
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
|
||||||
|
<GlobeIcon />
|
||||||
|
<span>Синхронизировать домены</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
|
||||||
|
<ServerIcon />
|
||||||
|
<span>Создать сервис</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
|
||||||
|
<FolderTreeIcon />
|
||||||
|
<span>Создать группу</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<span>Проверить сертификаты</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
value="Переключить тему"
|
||||||
|
onSelect={() => {
|
||||||
|
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PaletteIcon />
|
||||||
|
<span>Переключить тему</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Разделы">
|
<CommandGroup heading="Разделы">
|
||||||
{SECTION_ITEMS.map((item) => (
|
{SECTION_ITEMS.map((item) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -133,15 +164,57 @@ export function CommandPalette() {
|
|||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Действия">
|
<CommandGroup heading="Домены">
|
||||||
{ACTION_ITEMS.map((item) => (
|
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.label}
|
key={domain.id}
|
||||||
value={`${item.label} ${item.keywords}`}
|
value={`домен ${domain.zone_name}`}
|
||||||
onSelect={() => go(item.to)}
|
onSelect={() => {
|
||||||
|
setOpen(false)
|
||||||
|
void navigate({
|
||||||
|
to: '/domains/$domainId',
|
||||||
|
params: { domainId: String(domain.id) },
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<GlobeIcon />
|
||||||
<span>{item.label}</span>
|
<span>{domain.zone_name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Сервисы">
|
||||||
|
{services.slice(0, 8).map((service) => (
|
||||||
|
<CommandItem
|
||||||
|
key={service.id}
|
||||||
|
value={`сервис ${service.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false)
|
||||||
|
void navigate({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params: { serviceId: String(service.id) },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
<span>{service.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Группы">
|
||||||
|
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
|
||||||
|
<CommandItem
|
||||||
|
key={group.id}
|
||||||
|
value={`группа ${group.name} ${group.slug}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false)
|
||||||
|
void navigate({
|
||||||
|
to: '/groups/$groupId',
|
||||||
|
params: { groupId: String(group.id) },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderTreeIcon />
|
||||||
|
<span>{group.name}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export function DataFreshness() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
checked={live}
|
checked={live}
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={handleCheckedChange}
|
||||||
aria-label={live ? 'Выключить автообновление' : 'Включить автообновление'}
|
aria-label="Автообновление"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Fragment, useMemo } from 'react'
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { MenuIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -12,7 +13,9 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
|
||||||
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
@@ -45,6 +48,15 @@ export function SiteHeader() {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Меню и поиск"
|
||||||
|
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||||
|
>
|
||||||
|
<MenuIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||||
import type { SystemIncident } from '@/lib/system-attention'
|
import type { SystemIncident } from '@/lib/system-attention'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -168,7 +167,11 @@ export function SystemMonitorPopover() {
|
|||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Монитор системы"
|
aria-label={
|
||||||
|
spiking
|
||||||
|
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
|
||||||
|
: 'Состояние системы: норма'
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
@@ -188,14 +191,15 @@ export function SystemMonitorPopover() {
|
|||||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
<span className="text-foreground hidden text-xs font-medium sm:inline">
|
||||||
<Badge
|
|
||||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
|
||||||
size="xs"
|
|
||||||
className="h-4 px-1.5 text-[10px]"
|
|
||||||
>
|
|
||||||
{spiking ? 'Внимание' : 'Норма'}
|
{spiking ? 'Внимание' : 'Норма'}
|
||||||
</Badge>
|
</span>
|
||||||
|
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
|
||||||
|
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
|
||||||
|
{attention.servicesDown}
|
||||||
|
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
|
||||||
|
{attention.servicesDegraded + attention.certWarnings}
|
||||||
|
</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ import {
|
|||||||
redirectToPortalLogout,
|
redirectToPortalLogout,
|
||||||
resetPortalHandoff,
|
resetPortalHandoff,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
readContrast,
|
||||||
|
readDensity,
|
||||||
|
writeContrast,
|
||||||
|
writeDensity,
|
||||||
|
type ContrastMode,
|
||||||
|
type DensityMode,
|
||||||
|
} from '@/lib/appearance-mode'
|
||||||
|
|
||||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||||
|
|
||||||
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SegmentedChoice<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
options: readonly { value: T; label: string }[]
|
||||||
|
onChange: (value: T) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={label}
|
||||||
|
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isActive = value === option.value
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
size="xs"
|
||||||
|
variant={isActive ? 'secondary' : 'ghost'}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceToggles() {
|
||||||
|
const [contrast, setContrast] = useState<ContrastMode>('standard')
|
||||||
|
const [density, setDensity] = useState<DensityMode>('normal')
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContrast(readContrast())
|
||||||
|
setDensity(readDensity())
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
|
<span className="text-sm">Контраст</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SegmentedChoice
|
||||||
|
label="Контраст"
|
||||||
|
value={contrast}
|
||||||
|
options={[
|
||||||
|
{ value: 'standard', label: 'Обычный' },
|
||||||
|
{ value: 'high', label: 'Высокий' },
|
||||||
|
]}
|
||||||
|
onChange={(next) => {
|
||||||
|
setContrast(next)
|
||||||
|
writeContrast(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
|
<span className="text-sm">Плотность</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SegmentedChoice
|
||||||
|
label="Плотность"
|
||||||
|
value={density}
|
||||||
|
options={[
|
||||||
|
{ value: 'normal', label: 'Обычная' },
|
||||||
|
{ value: 'compact', label: 'Компакт' },
|
||||||
|
]}
|
||||||
|
onChange={(next) => {
|
||||||
|
setDensity(next)
|
||||||
|
writeDensity(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function initials(name: string, email: string): string {
|
function initials(name: string, email: string): string {
|
||||||
const base = name.trim() || email.trim()
|
const base = name.trim() || email.trim()
|
||||||
if (!base) return '?'
|
if (!base) return '?'
|
||||||
@@ -202,6 +302,7 @@ export function NavUser() {
|
|||||||
<ThemeSegmentedToggle />
|
<ThemeSegmentedToggle />
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<AppearanceToggles />
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
|||||||
<p className="text-muted-foreground text-sm">{description}</p>
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
'flex min-w-0 flex-col items-stretch gap-4',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useEffect, useRef } from 'react'
|
import { useMemo, useEffect, useRef } from 'react'
|
||||||
|
import { ActivityIcon } from 'lucide-react'
|
||||||
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
||||||
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
|||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
|
||||||
|
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
|
||||||
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
export function NodeHealthChart() {
|
||||||
|
return (
|
||||||
|
<Frame className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Здоровье нод</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Healthy / Unhealthy по проверкам health-check · последние 24 часа
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex min-h-52 flex-col">
|
||||||
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="Нет ряда за 24 часа"
|
||||||
|
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent
|
|||||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
export { ServiceFailoverPanel } from './service-failover-panel'
|
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart, NodeHealthChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
export {
|
export {
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { Link } from '@tanstack/react-router'
|
|||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
@@ -40,14 +39,18 @@ export type OpsKpiCard = KpiStatCardData
|
|||||||
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||||
export type KpiStatCard = KpiStatCardData
|
export type KpiStatCard = KpiStatCardData
|
||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
|
||||||
|
|
||||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
|
||||||
default: 'text-foreground',
|
default: 'outline',
|
||||||
warning: 'text-warning',
|
warning: 'warning-light',
|
||||||
destructive: 'text-destructive',
|
destructive: 'destructive-light',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
|
||||||
|
const STAT_ICON_CLASS =
|
||||||
|
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
|
||||||
|
|
||||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -67,7 +70,11 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
<Badge
|
||||||
|
variant={HINT_VARIANT[item.variant ?? 'default']}
|
||||||
|
size="sm"
|
||||||
|
className="max-w-full truncate"
|
||||||
|
>
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -78,43 +85,30 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
|
|
||||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
const footer = resolveFooter(item)
|
const footer = resolveFooter(item)
|
||||||
const valueVariant = item.variant ?? 'default'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<IconTile
|
<Item
|
||||||
variant="elevated"
|
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
</IconTile>
|
{item.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
|
||||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
{footer ? (
|
|
||||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
|
||||||
VALUE_VARIANT_CLASS[valueVariant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
{footer ? (
|
<div className="text-muted-foreground min-w-0 text-sm font-medium">
|
||||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
{item.label}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,24 +190,28 @@ export function KpiStatCard({
|
|||||||
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statGridClass(count: number) {
|
||||||
|
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
|
||||||
|
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
|
||||||
|
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
|
||||||
|
}
|
||||||
|
|
||||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
return (
|
return (
|
||||||
<Frame className="@container w-full">
|
<div className={cn('@container grid w-full', statGridClass(count))}>
|
||||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
{Array.from({ length: count }).map((_, index) => (
|
<Frame key={index}>
|
||||||
<FramePanel key={index} className="flex items-start gap-3">
|
<FramePanel className="flex flex-col items-start gap-6">
|
||||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
<Skeleton className="size-10.5 rounded-lg" />
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-7 w-16" />
|
<Skeleton className="h-7 w-16" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
<Skeleton className="h-5 w-28 rounded-full" />
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
))}
|
</Frame>
|
||||||
</div>
|
))}
|
||||||
</Frame>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,44 +230,10 @@ interface KpiStatGridProps {
|
|||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
|
||||||
const onActivate = resolveActivate(item)
|
|
||||||
const panelClass = panelClassName(item)
|
|
||||||
|
|
||||||
if (item.to) {
|
|
||||||
return (
|
|
||||||
<FramePanel className={panelClass}>
|
|
||||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
|
||||||
<KpiStatCardBody item={item} />
|
|
||||||
</Link>
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onActivate) {
|
|
||||||
return (
|
|
||||||
<FramePanel
|
|
||||||
className={panelClass}
|
|
||||||
onClick={onActivate}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
|
||||||
>
|
|
||||||
<KpiStatCardBody item={item} />
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FramePanel className={panelClass}>
|
|
||||||
<KpiStatCardBody item={item} />
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
|
||||||
* Preview: https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/blocks/application/stats/stats-12
|
||||||
*/
|
*/
|
||||||
export function KpiStatGrid({
|
export function KpiStatGrid({
|
||||||
items,
|
items,
|
||||||
@@ -301,26 +265,18 @@ export function KpiStatGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embedded) {
|
|
||||||
return (
|
|
||||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
|
||||||
{list.map((item, index) => (
|
|
||||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
<div className={cn('grid', statGridClass(list.length || 1))}>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
<KpiStatCardTile
|
||||||
|
key={kpiStatItemKey(item, index)}
|
||||||
|
item={item}
|
||||||
|
embedded={embedded}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,22 +63,11 @@ export function OpsDashboard({
|
|||||||
|
|
||||||
{afterKpi}
|
{afterKpi}
|
||||||
|
|
||||||
<section
|
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
|
||||||
aria-label="Аналитика"
|
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
|
||||||
>
|
|
||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
|
||||||
{queueDescription ? (
|
|
||||||
<p className="text-muted-foreground max-w-prose text-sm">
|
|
||||||
{queueDescription}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{queue}
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
|
||||||
|
|
||||||
export interface QuickActionItem {
|
export interface QuickActionItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +18,7 @@ export interface QuickActionItem {
|
|||||||
search?: Record<string, unknown>
|
search?: Record<string, unknown>
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
badgeVariant?: 'info-light' | 'warning-light'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuickActionGridProps {
|
interface QuickActionGridProps {
|
||||||
@@ -29,39 +28,10 @@ interface QuickActionGridProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
|
||||||
|
|
||||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|
||||||
return (
|
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
|
||||||
{action.icon ? (
|
|
||||||
<IconTile
|
|
||||||
variant="elevated"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
|
||||||
>
|
|
||||||
{action.icon}
|
|
||||||
</IconTile>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
|
||||||
<Badge variant="outline" size="sm" className="shrink-0">
|
|
||||||
Перейти
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
|
||||||
{action.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
* Quick links from card-12, with the artboard title, description and «Перейти».
|
||||||
* Preview: https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/card-12
|
||||||
|
* Docs: https://reui.io/blocks/application/card/card-12
|
||||||
*/
|
*/
|
||||||
export function QuickActionGrid({
|
export function QuickActionGrid({
|
||||||
actions,
|
actions,
|
||||||
@@ -72,28 +42,37 @@ export function QuickActionGrid({
|
|||||||
if (actions.length === 0) return null
|
if (actions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
<Frame className={cn('w-full', className)}>
|
||||||
{(title || description) && (
|
{title || description ? (
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
)}
|
) : null}
|
||||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<FramePanel
|
<Link
|
||||||
key={action.id}
|
key={action.id}
|
||||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
|
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||||
>
|
>
|
||||||
<Link
|
<FramePanel className="flex h-full flex-col gap-3 p-3.5">
|
||||||
to={action.to}
|
<span className={cn('[&_svg]:size-5', action.iconClassName)} aria-hidden="true">
|
||||||
search={action.search}
|
{action.icon}
|
||||||
className="focus-visible:outline-none"
|
</span>
|
||||||
aria-label={`${action.title}: ${action.description}`}
|
<div className="flex items-start justify-between gap-2">
|
||||||
>
|
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||||
<QuickActionBody action={action} />
|
{action.title}
|
||||||
</Link>
|
</h3>
|
||||||
</FramePanel>
|
<Badge variant={action.badgeVariant ?? 'info-light'} size="sm">
|
||||||
|
Перейти
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
|
||||||
|
</FramePanel>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -1,104 +1,67 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
const SETTINGS_TABS = [
|
||||||
id: string
|
{ to: '/settings/appearance', label: 'Внешний вид' },
|
||||||
to: string
|
{ to: '/settings/health', label: 'Health-check' },
|
||||||
label: string
|
{ to: '/settings/integrations', label: 'Интеграции' },
|
||||||
icon?: ReactNode
|
] as const
|
||||||
|
|
||||||
|
type SettingsTab = (typeof SETTINGS_TABS)[number]['to']
|
||||||
|
|
||||||
|
function activeTab(pathname: string): SettingsTab {
|
||||||
|
const match = SETTINGS_TABS.find(
|
||||||
|
(tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`),
|
||||||
|
)
|
||||||
|
return match?.to ?? '/settings/appearance'
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
/**
|
||||||
{
|
* Настройки CfSettings: вкладки внутри страницы, не боковой rail.
|
||||||
id: 'appearance',
|
* Строки секций — settings-3 SettingField / SettingRow.
|
||||||
to: '/settings/appearance',
|
* Preview: https://reui.io/preview/base/settings-3
|
||||||
label: 'Внешний вид',
|
* Preview: https://reui.io/preview/base/settings-16
|
||||||
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
* Docs: https://reui.io/blocks/application/settings
|
||||||
},
|
*/
|
||||||
{
|
export function SettingsShell() {
|
||||||
id: 'health',
|
const navigate = useNavigate()
|
||||||
to: '/settings/health',
|
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
||||||
label: 'Health-check',
|
const value = activeTab(pathname)
|
||||||
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'integrations',
|
|
||||||
to: '/settings/integrations',
|
|
||||||
label: 'Интеграции',
|
|
||||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
tabs?: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsShell({
|
|
||||||
title = 'Настройки',
|
|
||||||
description = 'Внешний вид, health-check и интеграции',
|
|
||||||
tabs = DEFAULT_TABS,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
<PageHeader
|
||||||
<PageHeader title={title} description={description} />
|
title="Настройки"
|
||||||
|
description="Интерфейс, health-check и интеграции"
|
||||||
<div
|
/>
|
||||||
className={cn(
|
<Tabs
|
||||||
'flex gap-5',
|
value={value}
|
||||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
onValueChange={(next) => {
|
||||||
)}
|
const tab = SETTINGS_TABS.find((item) => item.to === next)
|
||||||
>
|
if (!tab) return
|
||||||
{tabs.length > 1 ? (
|
void navigate({ to: tab.to })
|
||||||
<nav
|
}}
|
||||||
aria-label="Разделы настроек"
|
>
|
||||||
className={cn(
|
<TabsList variant="line" className="w-full justify-start gap-5">
|
||||||
'flex gap-1',
|
{SETTINGS_TABS.map((tab) => (
|
||||||
isMobile
|
<TabsTrigger
|
||||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
key={tab.to}
|
||||||
: 'w-44 shrink-0 flex-col',
|
value={tab.to}
|
||||||
)}
|
nativeButton={false}
|
||||||
|
render={<Link to={tab.to} />}
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tab.label}
|
||||||
const isActive = pathname.startsWith(tab.to)
|
</TabsTrigger>
|
||||||
return (
|
))}
|
||||||
<Link
|
</TabsList>
|
||||||
key={tab.id}
|
</Tabs>
|
||||||
to={tab.to}
|
<Outlet />
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { SettingsTab as SettingsTabConfig }
|
||||||
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS variable architecture for FramePanel theming:
|
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||||
*
|
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||||
* border-(--frame-panel-border-color). This means:
|
|
||||||
*
|
|
||||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
|
||||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
|
||||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
|
||||||
* :not() or !important needed
|
|
||||||
*/
|
*/
|
||||||
const frameVariants = cva(
|
const frameVariants = cva(
|
||||||
[
|
[
|
||||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
// Each rung is the radius that style's own shadcn Card resolves through, so
|
||||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||||
|
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||||
|
// invalid, which also left --frame-radius empty for anything reading it.
|
||||||
|
"[--frame-radius:var(--radius-xl)]",
|
||||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||||
// Default panel token values — overridden per-variant below
|
"",
|
||||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||||
|
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||||
|
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||||
|
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||||
|
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||||
|
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
@@ -32,14 +34,18 @@ const frameVariants = cva(
|
|||||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||||
inverse:
|
inverse:
|
||||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||||
ghost: "",
|
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||||
},
|
},
|
||||||
|
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||||
|
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||||
|
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||||
|
// which it stops reading as padding. No style-*.css overrides these.
|
||||||
spacing: {
|
spacing: {
|
||||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
|
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||||
default:
|
default:
|
||||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
|
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
|
||||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
|
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
|
||||||
},
|
},
|
||||||
stacked: {
|
stacked: {
|
||||||
true: [
|
true: [
|
||||||
@@ -55,8 +61,9 @@ const frameVariants = cva(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
dense: {
|
dense: {
|
||||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||||
|
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||||
false: "",
|
false: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,13 +105,11 @@ function FramePanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
/* `fit` sizes the panel to its content; otherwise it grows to fill
|
||||||
// via className overrides these by Tailwind source order - no ! needed.
|
the frame. */
|
||||||
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
|
||||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
|
||||||
!fit && "grow",
|
!fit && "grow",
|
||||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
|
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -100,34 +100,42 @@ export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
|
|||||||
isLoading={logQuery.isFetching}
|
isLoading={logQuery.isFetching}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<Frame className="w-full">
|
||||||
icon={ActivityIcon}
|
|
||||||
title="Нет ряда задержки"
|
|
||||||
description="Журнал health-log не содержит замеров latency. График появится после проверок с задержкой."
|
|
||||||
stackedIcon={false}
|
|
||||||
centered={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{history.length > 0 ? (
|
|
||||||
<Frame spacing="sm" className="w-full">
|
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Failover</FrameTitle>
|
<FrameTitle>Задержка и ошибки</FrameTitle>
|
||||||
<FrameDescription>События журнала смены адресов</FrameDescription>
|
<FrameDescription>Проба health-check · последние 24 часа</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel>
|
<FramePanel>
|
||||||
<FailoverTimeline events={[]} history={history} />
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="Нет ряда за 24 часа"
|
||||||
|
description="Журнал health-log не содержит замеров задержки. Точки не подставляются."
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={UnplugIcon}
|
|
||||||
title="Нет событий failover"
|
|
||||||
description="Журнал failover пуст — timeline строится только по его записям."
|
|
||||||
stackedIcon={false}
|
|
||||||
centered={false}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Frame spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>История failover</FrameTitle>
|
||||||
|
<FrameDescription>Переключения между нодами</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<FailoverTimeline events={[]} history={history} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={UnplugIcon}
|
||||||
|
title="Нет событий failover"
|
||||||
|
description="Журнал failover пуст — timeline строится только по его записям."
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
import type { ReactNode } from 'react'
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
applyAppearance(readContrast(), readDensity())
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextThemesProvider
|
<NextThemesProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const CONTRAST_KEY = 'cfdm.contrast'
|
||||||
|
const DENSITY_KEY = 'cfdm.density'
|
||||||
|
|
||||||
|
export type ContrastMode = 'standard' | 'high'
|
||||||
|
export type DensityMode = 'normal' | 'compact'
|
||||||
|
|
||||||
|
export function readContrast(): ContrastMode {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CONTRAST_KEY) === 'high' ? 'high' : 'standard'
|
||||||
|
} catch {
|
||||||
|
return 'standard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDensity(): DensityMode {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'normal'
|
||||||
|
} catch {
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAppearance(contrast: ContrastMode, density: DensityMode) {
|
||||||
|
document.documentElement.dataset.contrast = contrast
|
||||||
|
document.documentElement.dataset.density = density
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeContrast(contrast: ContrastMode) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONTRAST_KEY, contrast)
|
||||||
|
} catch {
|
||||||
|
// хранилище недоступно
|
||||||
|
}
|
||||||
|
applyAppearance(contrast, readDensity())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDensity(density: DensityMode) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DENSITY_KEY, density)
|
||||||
|
} catch {
|
||||||
|
// хранилище недоступно
|
||||||
|
}
|
||||||
|
applyAppearance(readContrast(), density)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Toaster } from '@cfdm/ui/components/sonner'
|
|||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import { queryClient } from './lib/queryClient'
|
import { queryClient } from './lib/queryClient'
|
||||||
import '@cfdm/ui/globals.css'
|
import '@cfdm/ui/globals.css'
|
||||||
|
import '@/styles/appearance-modes.css'
|
||||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
||||||
|
|
||||||
debugAgentLog('main.tsx:boot', 'app boot', {
|
debugAgentLog('main.tsx:boot', 'app boot', {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function CertificatesPage() {
|
|||||||
isLoading={checkMutation.isPending}
|
isLoading={checkMutation.isPending}
|
||||||
loadingLabel="Проверка…"
|
loadingLabel="Проверка…"
|
||||||
>
|
>
|
||||||
Запустить проверку
|
Проверить все
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
|
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { formatDate, sqliteUtcToIso } from '@/lib/format'
|
import { formatDate, sqliteUtcToIso } from '@/lib/format'
|
||||||
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput, type DomainListItem } from '@/lib/schemas'
|
import { type BulkUpdateDomainsInput, type DomainListItem } from '@/lib/schemas'
|
||||||
import {
|
import {
|
||||||
bulkUpdateDomains,
|
bulkUpdateDomains,
|
||||||
dnsKeys,
|
dnsKeys,
|
||||||
@@ -35,8 +34,8 @@ import { FormSheet } from '@/components/form-sheet'
|
|||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -56,9 +55,10 @@ export const Route = createFileRoute('/_auth/domains/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function DomainsPage() {
|
function DomainsPage() {
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [groupOpen, setGroupOpen] = useState(false)
|
||||||
|
const [groupValue, setGroupValue] = useState('none')
|
||||||
|
const selectedIdsRef = useRef<string[]>([])
|
||||||
const [syncOpen, setSyncOpen] = useState(false)
|
const [syncOpen, setSyncOpen] = useState(false)
|
||||||
const [importGroupId, setImportGroupId] = useState('')
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -84,24 +84,7 @@ function DomainsPage() {
|
|||||||
|
|
||||||
const filterFields = useDomainFilterFields(groupItems)
|
const filterFields = useDomainFilterFields(groupItems)
|
||||||
|
|
||||||
const form = useForm<CreateDomainInput>({
|
const form = useForm({ defaultValues: { group_id: '' } })
|
||||||
resolver: zodResolver(createDomainSchema),
|
|
||||||
defaultValues: { zone_name: '', group_id: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (body: { zone_name: string; group_id?: number }) =>
|
|
||||||
api.post('/api/v1/domains', body),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
|
||||||
form.reset({ zone_name: '', group_id: importGroupId })
|
|
||||||
setSheetOpen(false)
|
|
||||||
toast.success('Домен импортирован')
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
|
||||||
@@ -149,25 +132,20 @@ function DomainsPage() {
|
|||||||
enableSelection: true,
|
enableSelection: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreate = (values: CreateDomainInput) => {
|
|
||||||
createMutation.mutate({
|
|
||||||
zone_name: values.zone_name.trim(),
|
|
||||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImportGroupChange = (value: string | null) => {
|
|
||||||
const next = value === 'none' || !value ? '' : value
|
|
||||||
setImportGroupId(next)
|
|
||||||
form.setValue('group_id', next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const freshness = domainFreshnessLine(domains)
|
const freshness = domainFreshnessLine(domains)
|
||||||
|
|
||||||
|
function openGroupSheet() {
|
||||||
|
if (selectedIdsRef.current.length === 0) {
|
||||||
|
toast.message('Выберите домены в таблице')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGroupOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const primaryAction = (
|
const primaryAction = (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" onClick={() => setSheetOpen(true)}>
|
<Button type="button" variant="outline" onClick={openGroupSheet}>
|
||||||
Импортировать домен
|
Сменить группу
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -184,16 +162,17 @@ function DomainsPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Домены"
|
title="Домены"
|
||||||
description={
|
description="Импортированные зоны Cloudflare"
|
||||||
<span className="flex flex-col gap-1">
|
|
||||||
<span>Импортированные зоны Cloudflare</span>
|
|
||||||
{freshness ? (
|
|
||||||
<span className="tabular-nums">{freshness}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
actions={primaryAction}
|
actions={primaryAction}
|
||||||
/>
|
/>
|
||||||
|
{freshness ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
Cloudflare
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular-nums">{freshness}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<DomainKpiStats
|
<DomainKpiStats
|
||||||
domains={domains}
|
domains={domains}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
@@ -248,32 +227,23 @@ function DomainsPage() {
|
|||||||
}}
|
}}
|
||||||
onFilteredEmptyReset={() => setActiveTab('all')}
|
onFilteredEmptyReset={() => setActiveTab('all')}
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
selectionToolbar={({ selectedIds, clearSelection }) => (
|
selectionToolbar={({ selectedIds, clearSelection }) => {
|
||||||
<DomainsBulkToolbar
|
selectedIdsRef.current = selectedIds
|
||||||
count={selectedIds.length}
|
return (
|
||||||
isPending={bulkMutation.isPending}
|
<DomainsBulkToolbar
|
||||||
groupItems={groupItems}
|
count={selectedIds.length}
|
||||||
onAssignGroup={(groupId) => {
|
isPending={bulkMutation.isPending}
|
||||||
bulkMutation.mutate(
|
groupItems={groupItems}
|
||||||
{ ids: selectedIds.map(Number), group_id: groupId },
|
onAssignGroup={(groupId) => {
|
||||||
{ onSuccess: () => clearSelection() },
|
bulkMutation.mutate(
|
||||||
)
|
{ ids: selectedIds.map(Number), group_id: groupId },
|
||||||
}}
|
{ onSuccess: () => clearSelection() },
|
||||||
onSetEnvironment={(environment) => {
|
)
|
||||||
bulkMutation.mutate(
|
}}
|
||||||
{ ids: selectedIds.map(Number), environment },
|
onClear={clearSelection}
|
||||||
{ onSuccess: () => clearSelection() },
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onAddTag={(tag) => {
|
|
||||||
bulkMutation.mutate(
|
|
||||||
{ ids: selectedIds.map(Number), tags_add: [tag] },
|
|
||||||
{ onSuccess: () => clearSelection() },
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onClear={clearSelection}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: GlobeIcon,
|
icon: GlobeIcon,
|
||||||
title: 'Нет доменов',
|
title: 'Нет доменов',
|
||||||
@@ -292,43 +262,41 @@ function DomainsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormSheet
|
<FormSheet
|
||||||
open={sheetOpen}
|
open={groupOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setGroupOpen}
|
||||||
title="Импорт домена"
|
title="Сменить группу"
|
||||||
description="Добавить зону из аккаунта Cloudflare в менеджер"
|
description="Группа применится к выбранным доменам"
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={handleCreate}
|
onSubmit={() => {
|
||||||
|
const ids = selectedIdsRef.current.map(Number)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
bulkMutation.mutate(
|
||||||
|
{
|
||||||
|
ids,
|
||||||
|
group_id: groupValue === 'none' ? null : Number(groupValue),
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setGroupOpen(false) },
|
||||||
|
)
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={createMutation.isPending}
|
isLoading={bulkMutation.isPending}
|
||||||
loadingLabel="Импорт…"
|
loadingLabel="Применение…"
|
||||||
>
|
>
|
||||||
Импортировать
|
Применить
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FormFieldSimple
|
<FormFieldSimple label="Группа" htmlFor="change_group_id" hint="Домены без группы можно оставить без изменений">
|
||||||
label="Имя зоны"
|
|
||||||
htmlFor="zone_name"
|
|
||||||
error={form.formState.errors.zone_name}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="zone_name"
|
|
||||||
placeholder="example.com"
|
|
||||||
{...form.register('zone_name')}
|
|
||||||
aria-invalid={!!form.formState.errors.zone_name}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Группа" htmlFor="import_group_id">
|
|
||||||
<Select
|
<Select
|
||||||
items={groupItems}
|
items={groupItems}
|
||||||
value={importGroupId || 'none'}
|
value={groupValue}
|
||||||
onValueChange={handleImportGroupChange}
|
onValueChange={(value) => setGroupValue(value ?? 'none')}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="import_group_id" className="w-full">
|
<SelectTrigger id="change_group_id" className="w-full">
|
||||||
<SelectValue placeholder="Без группы" />
|
<SelectValue placeholder="Без группы" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -211,15 +211,7 @@ function GroupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = createButton
|
||||||
<>
|
|
||||||
{createButton}
|
|
||||||
<CatalogBoardToggle
|
|
||||||
view={view}
|
|
||||||
onViewChange={setView}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -332,8 +324,8 @@ function GroupsPage() {
|
|||||||
data={catalogRows}
|
data={catalogRows}
|
||||||
getRowId={(row) => String(row.id)}
|
getRowId={(row) => String(row.id)}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Группы не найдены',
|
title: 'Нет групп',
|
||||||
description: 'Создайте группу для организации доменов.',
|
description: 'Создайте первую группу и назначьте в неё домены.',
|
||||||
action: createButton,
|
action: createButton,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMemo, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useForm } from 'react-hook-form'
|
||||||
import { FolderTreeIcon, GlobeIcon, HashIcon } from 'lucide-react'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { GlobeIcon, HashIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
|
bulkUpdateDomains,
|
||||||
|
domainKeys,
|
||||||
domainsListQueryOptions,
|
domainsListQueryOptions,
|
||||||
groupDetailQueryOptions,
|
groupDetailQueryOptions,
|
||||||
|
groupKeys,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import type { CreateGroupInput } from '@/lib/schemas'
|
||||||
|
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
|
import { DetailPanel, KpiStatGrid, ResourcePage } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
createDefaultGroupDomainFilters,
|
createDefaultGroupDomainFilters,
|
||||||
groupDomainFilterFieldValue,
|
groupDomainFilterFieldValue,
|
||||||
@@ -52,55 +61,51 @@ function GroupDetailPage() {
|
|||||||
|
|
||||||
const filterFields = useGroupDomainFilterFields()
|
const filterFields = useGroupDomainFilterFields()
|
||||||
const columns = useGroupDomainColumns()
|
const columns = useGroupDomainColumns()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [picked, setPicked] = useState<number[]>([])
|
||||||
|
const addForm = useForm({ defaultValues: { ids: '' } })
|
||||||
|
const allDomains = useQuery({ ...domainsListQueryOptions(), enabled: addOpen })
|
||||||
|
const ungrouped = (allDomains.data ?? []).filter((domain) => domain.group_id == null)
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: CreateGroupInput) => api.patch(`/api/v1/groups/${id}`, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||||
|
setEditOpen(false)
|
||||||
|
toast.success('Группа сохранена')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => bulkUpdateDomains({ ids, group_id: id }),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||||
|
setPicked([])
|
||||||
|
setAddOpen(false)
|
||||||
|
toast.success(`Добавлено: ${data.updated}`)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось добавить домены')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
<>
|
||||||
К группам
|
<Button type="button" variant="outline" onClick={() => setEditOpen(true)} disabled={!group}>
|
||||||
</Button>
|
Изменить
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => setAddOpen(true)} disabled={!group}>
|
||||||
|
Добавить домены
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const metricCards = useMemo(() => {
|
|
||||||
if (!group) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'domains',
|
|
||||||
icon: <GlobeIcon aria-hidden="true" />,
|
|
||||||
label: 'Домены',
|
|
||||||
description: 'Зоны в этой группе',
|
|
||||||
footer: (
|
|
||||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
|
||||||
{group.domain_count}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'slug',
|
|
||||||
icon: <HashIcon aria-hidden="true" />,
|
|
||||||
label: 'Slug',
|
|
||||||
description: 'Идентификатор группы',
|
|
||||||
footer: (
|
|
||||||
<span className="text-foreground font-mono text-sm">{group.slug}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'group',
|
|
||||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
|
||||||
label: 'Группа',
|
|
||||||
description: group.name,
|
|
||||||
footer: (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0"
|
|
||||||
nativeButton={false}
|
|
||||||
render={<Link to="/groups" search={{ view: 'board' }} />}
|
|
||||||
>
|
|
||||||
Открыть канбан
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [group])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<DetailPanel>
|
<DetailPanel>
|
||||||
@@ -108,14 +113,31 @@ function GroupDetailPage() {
|
|||||||
title={group?.name ?? 'Группа'}
|
title={group?.name ?? 'Группа'}
|
||||||
description={
|
description={
|
||||||
group
|
group
|
||||||
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
? `Группа доменов · slug ${group.slug} · ${group.domain_count} домена`
|
||||||
: 'Домены в группе'
|
: 'Домены в группе'
|
||||||
}
|
}
|
||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isLoading && group ? (
|
{!isLoading && group ? (
|
||||||
<DetailPanel.Metrics cards={metricCards} />
|
<KpiStatGrid
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
id: 'domains',
|
||||||
|
icon: <GlobeIcon aria-hidden />,
|
||||||
|
label: 'Домены',
|
||||||
|
value: group.domain_count,
|
||||||
|
hint: 'в группе',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slug',
|
||||||
|
icon: <HashIcon aria-hidden />,
|
||||||
|
label: 'Slug',
|
||||||
|
value: group.slug,
|
||||||
|
hint: 'для API',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DetailPanel.Section>
|
<DetailPanel.Section>
|
||||||
@@ -140,21 +162,67 @@ function GroupDetailPage() {
|
|||||||
}}
|
}}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'В группе нет доменов',
|
title: 'В группе нет доменов',
|
||||||
description:
|
description: 'Добавьте домены, которые ещё не назначены в группу.',
|
||||||
'Перетащите домены на канбане групп или назначьте группу при импорте',
|
|
||||||
action: (
|
action: (
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setAddOpen(true)}>
|
||||||
variant="outline"
|
Добавить домены
|
||||||
nativeButton={false}
|
|
||||||
render={<Link to="/groups" />}
|
|
||||||
>
|
|
||||||
Открыть канбан групп
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
</DetailPanel>
|
</DetailPanel>
|
||||||
|
|
||||||
|
<DomainGroupEditSheet
|
||||||
|
mode="edit"
|
||||||
|
group={group ?? null}
|
||||||
|
open={editOpen}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onSave={(_groupId, body) => updateMutation.mutate(body)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormSheet
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
title="Добавить домены"
|
||||||
|
description="Только домены без группы"
|
||||||
|
form={addForm}
|
||||||
|
onSubmit={() => {
|
||||||
|
if (picked.length === 0) return
|
||||||
|
addMutation.mutate(picked)
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<Button type="submit" disabled={picked.length === 0 || addMutation.isPending}>
|
||||||
|
Добавить {picked.length || ''}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ungrouped.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Нет доменов без группы</p>
|
||||||
|
) : (
|
||||||
|
ungrouped.map((domain) => {
|
||||||
|
const on = picked.includes(domain.id)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={domain.id}
|
||||||
|
type="button"
|
||||||
|
variant={on ? 'secondary' : 'outline'}
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() =>
|
||||||
|
setPicked((current) =>
|
||||||
|
on ? current.filter((item) => item !== domain.id) : [...current, domain.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{domain.zone_name}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormSheet>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
@@ -14,11 +14,9 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
certificatesQueryOptions,
|
certificatesQueryOptions,
|
||||||
certSummaryQueryOptions,
|
|
||||||
dnsKeys,
|
dnsKeys,
|
||||||
domainKeys,
|
domainKeys,
|
||||||
domainsListQueryOptions,
|
domainsListQueryOptions,
|
||||||
groupsQueryOptions,
|
|
||||||
opsSummaryQueryOptions,
|
opsSummaryQueryOptions,
|
||||||
serviceGroupsQueryOptions,
|
serviceGroupsQueryOptions,
|
||||||
subdomainKeys,
|
subdomainKeys,
|
||||||
@@ -27,8 +25,7 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
CertStatusChart,
|
NodeHealthChart,
|
||||||
GroupDomainsChart,
|
|
||||||
OpsDashboard,
|
OpsDashboard,
|
||||||
QuickActionGrid,
|
QuickActionGrid,
|
||||||
AttentionQueue,
|
AttentionQueue,
|
||||||
@@ -39,7 +36,6 @@ import {
|
|||||||
type QuickActionItem,
|
type QuickActionItem,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
|
||||||
@@ -47,8 +43,6 @@ export const Route = createFileRoute('/_auth/')({
|
|||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
|
||||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
|
||||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||||
queryClient.ensureQueryData(certificatesQueryOptions()),
|
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||||
queryClient.ensureQueryData(opsSummaryQueryOptions()),
|
queryClient.ensureQueryData(opsSummaryQueryOptions()),
|
||||||
@@ -69,14 +63,12 @@ function DashboardPage() {
|
|||||||
error: domainsQueryError,
|
error: domainsQueryError,
|
||||||
refetch: refetchDomains,
|
refetch: refetchDomains,
|
||||||
} = useQuery(domainsListQueryOptions())
|
} = useQuery(domainsListQueryOptions())
|
||||||
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
|
|
||||||
const {
|
const {
|
||||||
data: certs,
|
data: certs,
|
||||||
isError: certsFailed,
|
isError: certsFailed,
|
||||||
error: certsQueryError,
|
error: certsQueryError,
|
||||||
refetch: refetchCerts,
|
refetch: refetchCerts,
|
||||||
} = useQuery(certificatesQueryOptions())
|
} = useQuery(certificatesQueryOptions())
|
||||||
const { data: groups } = useQuery(groupsQueryOptions())
|
|
||||||
const {
|
const {
|
||||||
data: serviceData,
|
data: serviceData,
|
||||||
isError: servicesFailed,
|
isError: servicesFailed,
|
||||||
@@ -131,7 +123,8 @@ function DashboardPage() {
|
|||||||
to: '/services',
|
to: '/services',
|
||||||
search: { domainId: undefined },
|
search: { domainId: undefined },
|
||||||
icon: <ServerIcon aria-hidden />,
|
icon: <ServerIcon aria-hidden />,
|
||||||
iconClassName: 'text-success',
|
iconClassName: 'text-warning',
|
||||||
|
badgeVariant: 'warning-light',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'certs',
|
id: 'certs',
|
||||||
@@ -155,23 +148,7 @@ function DashboardPage() {
|
|||||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||||
(serviceData?.ungrouped.length ?? 0)
|
(serviceData?.ungrouped.length ?? 0)
|
||||||
|
|
||||||
const isLoading = domainsLoading || summaryLoading
|
const isLoading = domainsLoading
|
||||||
|
|
||||||
const statusChartData = useMemo(
|
|
||||||
() => (summary ?? []).map(([status, count]) => ({ status, count })),
|
|
||||||
[summary],
|
|
||||||
)
|
|
||||||
|
|
||||||
const groupChartData = useMemo(() => {
|
|
||||||
const list = groups ?? []
|
|
||||||
return list
|
|
||||||
.map((g) => ({
|
|
||||||
name: g.name,
|
|
||||||
count: domains?.filter((d) => d.group_id === g.id).length ?? 0,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 6)
|
|
||||||
}, [groups, domains])
|
|
||||||
|
|
||||||
const [nowMs] = useState(() => Date.now())
|
const [nowMs] = useState(() => Date.now())
|
||||||
|
|
||||||
@@ -216,25 +193,6 @@ function DashboardPage() {
|
|||||||
|
|
||||||
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading) return
|
|
||||||
debugAgentLog(
|
|
||||||
'index.tsx:dashboard-data',
|
|
||||||
'dashboard chart inputs',
|
|
||||||
{
|
|
||||||
buildStamp: DEBUG_BUILD_STAMP,
|
|
||||||
summaryRaw: summary ?? null,
|
|
||||||
statusChartLen: statusChartData.length,
|
|
||||||
statusChartData,
|
|
||||||
groupChartLen: groupChartData.length,
|
|
||||||
groupChartData,
|
|
||||||
domainsLen: domains?.length ?? 0,
|
|
||||||
groupsLen: groups?.length ?? 0,
|
|
||||||
},
|
|
||||||
'C',
|
|
||||||
)
|
|
||||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
|
||||||
|
|
||||||
const kpiCards: KpiStatCard[] = [
|
const kpiCards: KpiStatCard[] = [
|
||||||
{
|
{
|
||||||
id: 'domains',
|
id: 'domains',
|
||||||
@@ -313,13 +271,7 @@ function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
charts={
|
charts={<NodeHealthChart />}
|
||||||
<>
|
|
||||||
{/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */}
|
|
||||||
<CertStatusChart data={statusChartData} />
|
|
||||||
<GroupDomainsChart data={groupChartData} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
queue={
|
queue={
|
||||||
<AttentionQueue
|
<AttentionQueue
|
||||||
columns={[
|
columns={[
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
@@ -217,12 +216,15 @@ function ServiceLayout() {
|
|||||||
<DetailPanel>
|
<DetailPanel>
|
||||||
<DetailPanel.Header
|
<DetailPanel.Header
|
||||||
title={service.name}
|
title={service.name}
|
||||||
description="Ноды, проверки и маршрутизация"
|
description={`Domain → Service → Node → Health → Failover · группа «${groups.find((group) => group.id === service.service_group_id)?.name ?? 'Без группы'}»`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
|
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
|
||||||
Изменить
|
Изменить
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" onClick={() => setAddNodeOpen(true)}>
|
||||||
|
Добавить ноду
|
||||||
|
</Button>
|
||||||
{service.enabled ? (
|
{service.enabled ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Выключить сервис?"
|
title="Выключить сервис?"
|
||||||
@@ -240,9 +242,6 @@ function ServiceLayout() {
|
|||||||
onConfirm={() => disableMutation.mutate()}
|
onConfirm={() => disableMutation.mutate()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="button" onClick={() => setAddNodeOpen(true)}>
|
|
||||||
Добавить ноду
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -253,7 +252,14 @@ function ServiceLayout() {
|
|||||||
id: 'status',
|
id: 'status',
|
||||||
icon: <ActivityIcon />,
|
icon: <ActivityIcon />,
|
||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
value: displayHealth === 'up' ? 'OK' : displayHealth,
|
value:
|
||||||
|
displayHealth === 'up'
|
||||||
|
? 'Up'
|
||||||
|
: displayHealth === 'degraded'
|
||||||
|
? 'Slow'
|
||||||
|
: displayHealth === 'down'
|
||||||
|
? 'Down'
|
||||||
|
: displayHealth,
|
||||||
variant:
|
variant:
|
||||||
displayHealth === 'down'
|
displayHealth === 'down'
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
@@ -266,14 +272,17 @@ function ServiceLayout() {
|
|||||||
: displayHealth === 'degraded'
|
: displayHealth === 'degraded'
|
||||||
? 'text-warning'
|
? 'text-warning'
|
||||||
: 'text-success',
|
: 'text-success',
|
||||||
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
|
hint:
|
||||||
|
service.health_latency_ms != null
|
||||||
|
? `${service.health_latency_ms} мс`
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fqdn',
|
id: 'fqdn',
|
||||||
icon: <GlobeIcon />,
|
icon: <GlobeIcon />,
|
||||||
label: 'FQDN',
|
label: 'FQDN',
|
||||||
value: String(service.domains.length),
|
value: service.domains[0]?.fqdn ?? '—',
|
||||||
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
hint: service.domains[0]?.fqdn ? 'основной поддомен' : 'Нет привязанных FQDN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ip',
|
id: 'ip',
|
||||||
|
|||||||
@@ -31,12 +31,8 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||||
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
||||||
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
|
||||||
import { type ServiceCatalogRow } from '@/components/columns/services-columns'
|
import { type ServiceCatalogRow } from '@/components/columns/services-columns'
|
||||||
import {
|
import { ServicesGroupedCatalog } from '@/components/services/services-grouped-catalog'
|
||||||
ServicesAddMenu,
|
|
||||||
ServicesGroupedCatalog,
|
|
||||||
} from '@/components/services/services-grouped-catalog'
|
|
||||||
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
||||||
import { useServicesSelection } from '@/hooks/use-services-selection'
|
import { useServicesSelection } from '@/hooks/use-services-selection'
|
||||||
import {
|
import {
|
||||||
@@ -255,15 +251,6 @@ function ServicesPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setView(next: 'catalog' | 'board') {
|
|
||||||
navigate({
|
|
||||||
search: (prev) => ({
|
|
||||||
...prev,
|
|
||||||
view: next === 'board' ? 'board' : undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invalidateAll() {
|
async function invalidateAll() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||||
@@ -568,17 +555,14 @@ function ServicesPage() {
|
|||||||
? 'Перетаскивание отключено при фильтре по домену'
|
? 'Перетаскивание отключено при фильтре по домену'
|
||||||
: 'Перетащите сервис между группами или измените порядок в списке'
|
: 'Перетащите сервис между группами или измените порядок в списке'
|
||||||
|
|
||||||
const addMenu = (
|
|
||||||
<ServicesAddMenu
|
|
||||||
onAddService={() => handleOpenCreateService()}
|
|
||||||
onAddGroup={() => setCreateGroupSheetOpen(true)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<>
|
<>
|
||||||
{addMenu}
|
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||||
<CatalogBoardToggle view={view} onViewChange={setView} />
|
Создать группу сервисов
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => handleOpenCreateService()}>
|
||||||
|
Создать сервис
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
html[data-contrast='high'] {
|
||||||
|
--border: color-mix(in oklch, var(--foreground) 46%, var(--background));
|
||||||
|
--input: color-mix(in oklch, var(--foreground) 46%, var(--background));
|
||||||
|
--ring: var(--foreground);
|
||||||
|
--muted-foreground: color-mix(in oklch, var(--foreground) 78%, var(--background));
|
||||||
|
--sidebar-border: color-mix(in oklch, var(--foreground) 46%, var(--sidebar));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-density='compact'] {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-density='compact'] #main-content > div {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
|
||||||
const buttonGroupVariants = cva(
|
const buttonGroupVariants = cva(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
import { Label } from "@cfdm/ui/components/label"
|
import { Label } from "@cfdm/ui/components/label"
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ function FieldLabel({
|
|||||||
<Label
|
<Label
|
||||||
data-slot="field-label"
|
data-slot="field-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
import { Input } from "@cfdm/ui/components/input"
|
import { Input } from "@cfdm/ui/components/input"
|
||||||
import { Textarea } from "@cfdm/ui/components/textarea"
|
import { Textarea } from "@cfdm/ui/components/textarea"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import * as React from "react"
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
|
||||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react"
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Switch({
|
function Switch({
|
||||||
@@ -14,7 +15,7 @@ function Switch({
|
|||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
import { type VariantProps } from "class-variance-authority"
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
import { toggleVariants } from "@cfdm/ui/components/toggle"
|
import { toggleVariants } from "@cfdm/ui/components/toggle"
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
const ToggleGroupContext = React.createContext<
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
const toggleVariants = cva(
|
const toggleVariants = cva(
|
||||||
|
|||||||
Reference in new issue
Block a user