diff --git a/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx b/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx
new file mode 100644
index 0000000..a0b64c2
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx
@@ -0,0 +1,356 @@
+"use client"
+
+import * as React from "react"
+import { Badge } from "@/components/reui/badge"
+import {
+ Frame,
+ FrameHeader,
+ FramePanel,
+} from "@/components/reui/frame"
+import {
+ Timeline,
+ TimelineContent,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineSeparator,
+ TimelineTitle,
+} from "@/components/reui/timeline"
+import { toast } from "sonner"
+
+import { cn } from "@cfdm/ui/lib/utils"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@cfdm/ui/components/avatar"
+import { Button } from "@cfdm/ui/components/button"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@cfdm/ui/components/collapsible"
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@cfdm/ui/components/empty"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@cfdm/ui/components/select"
+import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs"
+import {
+ AUDIT_DAYS,
+ FILTER_OPTIONS,
+ RANGE_OPTIONS,
+ severityDotClass,
+ severityLabel,
+ severityVariant,
+ type AuditEvent,
+ type EventType,
+} from "./data"
+import { ChevronRightIcon, CopyIcon, CalendarIcon, DownloadIcon, FilterIcon } from "lucide-react"
+
+const TOTAL_EVENTS = AUDIT_DAYS.reduce((sum, day) => sum + day.events.length, 0)
+
+function copyValue(value: string) {
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
+ void navigator.clipboard.writeText(value).catch(() => undefined)
+ }
+}
+
+// ── Single audit event row (reuses timeline-1 Collapsible-in-Frame grammar) ──
+function EventRow({
+ event,
+ isLast,
+ step,
+ defaultOpen,
+}: {
+ event: AuditEvent
+ isLast: boolean
+ step: number
+ defaultOpen: boolean
+}) {
+ const [open, setOpen] = React.useState(defaultOpen)
+
+ return (
+
+
+
+
+
+ {event.action}
+
+
+
+ {severityLabel[event.severity]}
+
+ {event.time}
+
+
+ {event.icon}
+
+
+
+
+
+ setOpen(nextOpen)}
+ className="group/collapsible"
+ >
+
+
+
+
+
+
+ {event.actor.initials}
+
+
+
+ {event.actor.name}, {event.label}
+
+
+
+
+
+
+
+
+
+
+
+ {event.target}
+
+
+
+
+ {event.actor.email}
+
+
+
+
+ {event.ip}
+
+ {event.location}
+
+
+
+
+
+ {event.detail.sessionId}
+
+
+
+
+
+ {event.detail.reason}
+
+
+
+
+ {event.ref}
+
+ {
+ copyValue(event.ref)
+ toast.success("Reference copied", {
+ description: `${event.ref} is on your clipboard.`,
+ })
+ }}
+ >
+
+ Copy reference
+
+
+
+
+
+
+
+
+ )
+}
+
+function DetailRow({
+ label,
+ children,
+}: {
+ label: string
+ children: React.ReactNode
+}) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+export function AuditLogTimeline() {
+ const [filter, setFilter] = React.useState(["All"])
+ const [range, setRange] = React.useState("24h")
+
+ const activeFilter = (filter[0] ?? "All") as EventType | "All"
+
+ const visibleDays = React.useMemo(() => {
+ if (activeFilter === "All") return AUDIT_DAYS
+ return AUDIT_DAYS.map((day) => ({
+ ...day,
+ events: day.events.filter((event) => event.type === activeFilter),
+ })).filter((day) => day.events.length > 0)
+ }, [activeFilter])
+
+ const visibleCount = visibleDays.reduce(
+ (sum, day) => sum + day.events.length,
+ 0
+ )
+
+ const handleExport = () => {
+ toast.success("Export ready", {
+ description: `${visibleCount} events queued as CSV. Link valid for 24 hours.`,
+ })
+ }
+
+ return (
+
+ {/* ── Content header (title + filter chips + range + export) ── */}
+
+
+
+
+ Audit Log
+
+
+ {TOTAL_EVENTS} events in Acme Cloud workspace
+
+
+
+
+ value && setRange(value)}
+ items={RANGE_OPTIONS}
+ >
+
+
+
+
+
+
+ {RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ Export CSV
+
+
+
+
+
value && setFilter([value])}
+ >
+
+ {FILTER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {visibleDays.length === 0 ? (
+
+
+
+
+
+ No {activeFilter} events
+
+ No {activeFilter} events in the last 24 hours. Try another type or
+ widen the range.
+
+
+
+ setFilter(["All"])}
+ >
+ Clear filter
+
+
+
+ ) : (
+
+ {visibleDays.map((day) => (
+
+
+ {day.date}
+
+
+ {day.events.map((event, index) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-users-6/components/data.tsx b/apps/web/src/components/blocks/solution-users-6/components/data.tsx
new file mode 100644
index 0000000..4daf019
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-users-6/components/data.tsx
@@ -0,0 +1,407 @@
+import type { BadgeProps } from "@/components/reui/badge"
+import { CircleCheckIcon, TriangleAlertIcon, ArrowLeftRightIcon, MailIcon, ShieldCheckIcon, UsersIcon, RefreshCwIcon, LogOutIcon, KeyRoundIcon, DatabaseIcon } from "lucide-react"
+
+// ── Audit log world (Acme Cloud workspace) ──
+// Severity drives the timeline indicator + the inline severity badge. Event
+// type drives the filter chips. Each event carries an actor (avatar + email +
+// IP), a target, and an expandable detail block (session id, reason).
+
+export type EventSeverity = "info" | "notice" | "critical"
+
+export type EventType = "Auth" | "Roles" | "SSO/SCIM" | "Sessions" | "API"
+
+export type AuditActor = {
+ name: string
+ email: string
+ avatar: string
+ initials: string
+}
+
+export type AuditEvent = {
+ id: string
+ ref: string
+ type: EventType
+ action: string
+ label: string
+ target: string
+ severity: EventSeverity
+ time: string
+ actor: AuditActor
+ ip: string
+ location: string
+ icon: React.ReactNode
+ detail: { sessionId: string; reason: string }
+}
+
+export type AuditDay = {
+ id: number
+ date: string
+ events: AuditEvent[]
+}
+
+export type FilterOption = { value: EventType | "All"; label: string }
+
+export type RangeOption = { value: string; label: string }
+
+// ── Filter chips (event-type) ──
+export const FILTER_OPTIONS: FilterOption[] = [
+ { value: "All", label: "All" },
+ { value: "Auth", label: "Auth" },
+ { value: "Roles", label: "Roles" },
+ { value: "SSO/SCIM", label: "SSO/SCIM" },
+ { value: "Sessions", label: "Sessions" },
+ { value: "API", label: "API" },
+]
+
+// ── Date-range select ──
+export const RANGE_OPTIONS: RangeOption[] = [
+ { value: "24h", label: "Last 24 hours" },
+ { value: "7d", label: "Last 7 days" },
+ { value: "30d", label: "Last 30 days" },
+ { value: "90d", label: "Last 90 days" },
+]
+
+// ── Severity → badge variant + indicator dot ──
+export const severityVariant: Record = {
+ info: "success-outline",
+ notice: "warning-outline",
+ critical: "destructive-outline",
+}
+
+export const severityLabel: Record = {
+ info: "Info",
+ notice: "Notice",
+ critical: "Critical",
+}
+
+export const severityDotClass: Record = {
+ info: "bg-success",
+ notice: "bg-warning",
+ critical: "bg-destructive",
+}
+
+const MIRA: AuditActor = {
+ name: "Mira Stone",
+ email: "mira.stone@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
+ initials: "MS",
+}
+const LEO: AuditActor = {
+ name: "Leo Grant",
+ email: "leo.grant@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
+ initials: "LG",
+}
+const SANA: AuditActor = {
+ name: "Sana Qureshi",
+ email: "sana.qureshi@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
+ initials: "SQ",
+}
+const SARAH: AuditActor = {
+ name: "Sarah Chen",
+ email: "sarah.chen@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
+ initials: "SC",
+}
+const DAVID: AuditActor = {
+ name: "David Kim",
+ email: "david.kim@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
+ initials: "DK",
+}
+const KENJI: AuditActor = {
+ name: "Kenji Tan",
+ email: "kenji.tan@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
+ initials: "KT",
+}
+const OMAR: AuditActor = {
+ name: "Omar Haddad",
+ email: "omar.haddad@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
+ initials: "OH",
+}
+const NORA: AuditActor = {
+ name: "Nora Vale",
+ email: "nora.vale@acmecloud.com",
+ avatar:
+ "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
+ initials: "NV",
+}
+
+const authIcon = (
+
+)
+const authFailIcon = (
+
+)
+const roleIcon = (
+
+)
+const inviteIcon = (
+
+)
+const ssoIcon = (
+
+)
+const scimIcon = (
+
+)
+const mfaIcon = (
+
+)
+const sessionIcon = (
+
+)
+const apiIcon = (
+
+)
+const exportIcon = (
+
+)
+
+// ── Audit events grouped by day (newest first) ──
+export const AUDIT_DAYS: AuditDay[] = [
+ {
+ id: 1,
+ date: "Today, Jun 17",
+ events: [
+ {
+ id: "e1",
+ ref: "evt_9f3a21c8",
+ type: "Auth",
+ action: "Login failed",
+ label: "Password rejected",
+ target: "kenji.tan@acmecloud.com",
+ severity: "critical",
+ time: "2:14 PM",
+ actor: KENJI,
+ ip: "192.0.2.51",
+ location: "Berlin",
+ icon: authFailIcon,
+ detail: {
+ sessionId: "sess_b71e0d44",
+ reason: "3 failed attempts in 5 minutes, account temporarily locked",
+ },
+ },
+ {
+ id: "e2",
+ ref: "evt_71b0a9d2",
+ type: "Roles",
+ action: "Role changed",
+ label: "Member to Admin",
+ target: "Sana Qureshi",
+ severity: "notice",
+ time: "1:02 PM",
+ actor: LEO,
+ ip: "192.0.2.14",
+ location: "San Francisco",
+ icon: roleIcon,
+ detail: {
+ sessionId: "sess_c98a2f10",
+ reason: "Promotion approved by Mira Stone, scope raised to Write",
+ },
+ },
+ {
+ id: "e3",
+ ref: "evt_4c2d80ae",
+ type: "Sessions",
+ action: "Session revoked",
+ label: "Chrome on Windows",
+ target: "David Kim",
+ severity: "notice",
+ time: "11:48 AM",
+ actor: SARAH,
+ ip: "192.0.2.22",
+ location: "Seattle",
+ icon: sessionIcon,
+ detail: {
+ sessionId: "sess_5d1c6b09",
+ reason: "Revoked from a stale device, last active 14 days ago",
+ },
+ },
+ {
+ id: "e4",
+ ref: "evt_2a6f13bb",
+ type: "Auth",
+ action: "Login success",
+ label: "SSO via Okta",
+ target: "mira.stone@acmecloud.com",
+ severity: "info",
+ time: "9:05 AM",
+ actor: MIRA,
+ ip: "192.0.2.14",
+ location: "San Francisco",
+ icon: authIcon,
+ detail: {
+ sessionId: "sess_a02d7e58",
+ reason: "Passkey verified, session valid for 12 hours",
+ },
+ },
+ ],
+ },
+ {
+ id: 2,
+ date: "Yesterday, Jun 16",
+ events: [
+ {
+ id: "e5",
+ ref: "evt_88e1c5f0",
+ type: "API",
+ action: "API key created",
+ label: "Production, ci-deploy",
+ target: "key_3f9a...c712",
+ severity: "notice",
+ time: "6:21 PM",
+ actor: DAVID,
+ ip: "192.0.2.31",
+ location: "Seattle",
+ icon: apiIcon,
+ detail: {
+ sessionId: "sess_7b40e1aa",
+ reason: "Scopes: deployments:write, logs:read, expires in 90 days",
+ },
+ },
+ {
+ id: "e6",
+ ref: "evt_15d7a3e9",
+ type: "SSO/SCIM",
+ action: "SSO config changed",
+ label: "Okta to Microsoft Entra ID",
+ target: "Acme Cloud workspace",
+ severity: "critical",
+ time: "4:37 PM",
+ actor: MIRA,
+ ip: "192.0.2.14",
+ location: "San Francisco",
+ icon: ssoIcon,
+ detail: {
+ sessionId: "sess_e21f9c03",
+ reason: "Default identity provider switched, 68 members affected",
+ },
+ },
+ {
+ id: "e7",
+ ref: "evt_6b094d27",
+ type: "SSO/SCIM",
+ action: "SCIM provision",
+ label: "4 members imported",
+ target: "Engineering team",
+ severity: "info",
+ time: "4:30 PM",
+ actor: LEO,
+ ip: "192.0.2.14",
+ location: "San Francisco",
+ icon: scimIcon,
+ detail: {
+ sessionId: "sess_d4c7b210",
+ reason: "JIT provisioning from Entra ID, 80 of 80 seats reconciled",
+ },
+ },
+ {
+ id: "e8",
+ ref: "evt_33a8e0c1",
+ type: "Auth",
+ action: "MFA reset",
+ label: "Authenticator re-enrolled",
+ target: "Omar Haddad",
+ severity: "notice",
+ time: "2:10 PM",
+ actor: SARAH,
+ ip: "192.0.2.22",
+ location: "Seattle",
+ icon: mfaIcon,
+ detail: {
+ sessionId: "sess_9f0b2d6e",
+ reason: "Lost device reported, TOTP factor reset by admin",
+ },
+ },
+ {
+ id: "e9",
+ ref: "evt_07c4f2a5",
+ type: "Roles",
+ action: "Member invited",
+ label: "Guest, Support Agent",
+ target: "nora.vale@acmecloud.com",
+ severity: "info",
+ time: "10:55 AM",
+ actor: MIRA,
+ ip: "192.0.2.14",
+ location: "San Francisco",
+ icon: inviteIcon,
+ detail: {
+ sessionId: "sess_1ab39e7c",
+ reason: "Invite expires in 7 days, scope set to Read",
+ },
+ },
+ ],
+ },
+ {
+ id: 3,
+ date: "Jun 15",
+ events: [
+ {
+ id: "e10",
+ ref: "evt_5e2b9114",
+ type: "API",
+ action: "Data export",
+ label: "Audit log, CSV",
+ target: "8,420 events",
+ severity: "notice",
+ time: "5:42 PM",
+ actor: OMAR,
+ ip: "192.0.2.40",
+ location: "Toronto",
+ icon: exportIcon,
+ detail: {
+ sessionId: "sess_4c8d1f93",
+ reason: "Export covered 90 days, download link valid for 24 hours",
+ },
+ },
+ {
+ id: "e11",
+ ref: "evt_9012ad6f",
+ type: "Sessions",
+ action: "Session revoked",
+ label: "Safari on iOS",
+ target: "Nora Vale",
+ severity: "info",
+ time: "3:18 PM",
+ actor: NORA,
+ ip: "192.0.2.47",
+ location: "Austin",
+ icon: sessionIcon,
+ detail: {
+ sessionId: "sess_2f7a0c61",
+ reason: "Signed out of all other devices from account settings",
+ },
+ },
+ {
+ id: "e12",
+ ref: "evt_a4f60b38",
+ type: "Auth",
+ action: "Login success",
+ label: "Password, 2FA passed",
+ target: "sana.qureshi@acmecloud.com",
+ severity: "info",
+ time: "8:47 AM",
+ actor: SANA,
+ ip: "192.0.2.33",
+ location: "London",
+ icon: authIcon,
+ detail: {
+ sessionId: "sess_88be4d02",
+ reason: "Security key verified, new device added to trusted list",
+ },
+ },
+ ],
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-users-6/page.tsx b/apps/web/src/components/blocks/solution-users-6/page.tsx
new file mode 100644
index 0000000..6716616
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-users-6/page.tsx
@@ -0,0 +1,9 @@
+import { AuditLogTimeline } from "./components/audit-log-timeline"
+
+export function Page() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/domain/audit-diff.tsx b/apps/web/src/components/domain/audit-diff.tsx
new file mode 100644
index 0000000..8211ea9
--- /dev/null
+++ b/apps/web/src/components/domain/audit-diff.tsx
@@ -0,0 +1,27 @@
+import { formatDiffValue, diffFieldEntries } from '@/components/domain/audit-labels'
+
+interface AuditDiffProps {
+ diff: Record | null | undefined
+ className?: string
+}
+
+/** Поля diff как список ключ → значение (не raw JSON). */
+export function AuditDiff({ diff, className }: AuditDiffProps) {
+ const entries = diffFieldEntries(diff)
+ if (entries.length === 0) {
+ return Нет деталей изменений
+ }
+
+ return (
+
+ {entries.map(([key, value]) => (
+
+
{key}
+
+ {formatDiffValue(value)}
+
+
+ ))}
+
+ )
+}
diff --git a/apps/web/src/components/domain/audit-labels.ts b/apps/web/src/components/domain/audit-labels.ts
new file mode 100644
index 0000000..f2cc3b0
--- /dev/null
+++ b/apps/web/src/components/domain/audit-labels.ts
@@ -0,0 +1,65 @@
+export const AUDIT_ENTITY_LABELS: Record = {
+ vps: 'VPS',
+ payment: 'Платёж',
+ providerAccount: 'Аккаунт',
+ provider: 'Хостер',
+ settings: 'Настройки',
+ balanceLedger: 'Баланс',
+ serverProject: 'Проект',
+}
+
+export const ACTION_LABELS: Record = {
+ create: 'Создание',
+ update: 'Изменение',
+ delete: 'Удаление',
+}
+
+export type AuditAction = 'create' | 'update' | 'delete' | string
+
+export type AuditActionBadgeVariant =
+ | 'success-light'
+ | 'info-light'
+ | 'destructive-light'
+ | 'outline'
+
+export function auditEntityLabel(entity: string): string {
+ return AUDIT_ENTITY_LABELS[entity] ?? entity
+}
+
+export function auditActionLabel(action: string): string {
+ return ACTION_LABELS[action] ?? action
+}
+
+export function auditActionBadgeVariant(action: string): AuditActionBadgeVariant {
+ if (action === 'create') return 'success-light'
+ if (action === 'update') return 'info-light'
+ if (action === 'delete') return 'destructive-light'
+ return 'outline'
+}
+
+export function formatDiffValue(value: unknown): string {
+ if (value == null) return '—'
+ if (typeof value === 'string') return value || '—'
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+}
+
+export function diffFieldEntries(diff: Record | null | undefined) {
+ if (!diff) return []
+ return Object.entries(diff)
+}
+
+export function diffPreview(diff: Record | null | undefined, maxKeys = 3): string {
+ const entries = diffFieldEntries(diff)
+ if (entries.length === 0) return '—'
+ const head = entries
+ .slice(0, maxKeys)
+ .map(([key, value]) => `${key}: ${formatDiffValue(value)}`)
+ .join(', ')
+ const rest = entries.length - maxKeys
+ return rest > 0 ? `${head} (+${rest})` : head
+}
diff --git a/apps/web/src/components/domain/audit-timeline.tsx b/apps/web/src/components/domain/audit-timeline.tsx
new file mode 100644
index 0000000..d504dd0
--- /dev/null
+++ b/apps/web/src/components/domain/audit-timeline.tsx
@@ -0,0 +1,227 @@
+import { useMemo, useState } from 'react'
+import { Link } from '@tanstack/react-router'
+import {
+ ChevronRightIcon,
+ HistoryIcon,
+ PencilIcon,
+ PlusIcon,
+ Trash2Icon,
+ UserRoundIcon,
+} from 'lucide-react'
+
+import { Badge } from '@/components/reui/badge'
+import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
+import {
+ Timeline,
+ TimelineContent,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineSeparator,
+ TimelineTitle,
+} from '@/components/reui/timeline'
+import { AuditDiff } from '@/components/domain/audit-diff'
+import {
+ auditActionBadgeVariant,
+ auditActionLabel,
+ auditEntityLabel,
+ diffFieldEntries,
+} from '@/components/domain/audit-labels'
+import { cn } from '@cfdm/ui/lib/utils'
+import { Button } from '@cfdm/ui/components/button'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@cfdm/ui/components/collapsible'
+
+export interface AuditRow {
+ id: string
+ entity: string
+ entityId: string
+ action: string
+ diff: Record | null
+ actorUserId?: string | null
+ createdAt: string
+}
+
+interface AuditTimelineProps {
+ rows: AuditRow[]
+}
+
+function actionIcon(action: string) {
+ if (action === 'create') return
+ if (action === 'delete') return
+ if (action === 'update') return
+ return
+}
+
+function dayKey(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return iso
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function dayLabel(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return iso
+ return d.toLocaleDateString('ru-RU', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ })
+}
+
+function timeLabel(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return '—'
+ return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+
+function EntityIdLink({ entity, entityId }: { entity: string; entityId: string }) {
+ if (entity === 'vps') {
+ return (
+ }
+ >
+ {entityId}
+
+ )
+ }
+ return {entityId}
+}
+
+function EventRow({
+ row,
+ step,
+ isLast,
+ defaultOpen,
+}: {
+ row: AuditRow
+ step: number
+ isLast: boolean
+ defaultOpen: boolean
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ const fieldCount = diffFieldEntries(row.diff).length
+ const actor = row.actorUserId?.trim() || 'система'
+
+ return (
+
+
+
+
+
+ {auditActionLabel(row.action)}
+
+
+ {auditEntityLabel(row.entity)}
+
+ {timeLabel(row.createdAt)}
+
+
+ {actionIcon(row.action)}
+
+
+
+
+
+
+
+
+
+
+ {actor}
+ ·
+
+ {fieldCount > 0 ? (
+
+ {fieldCount} {fieldCount === 1 ? 'поле' : 'полей'}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
Сущность
+ {auditEntityLabel(row.entity)}
+
+
+
ID
+
+
+
+
+
+
Актор
+ {actor}
+
+
+
Время
+
+ {new Date(row.createdAt).toLocaleString('ru-RU')}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/** Day-grouped audit timeline — DNA solution-users-6. */
+export function AuditTimeline({ rows }: AuditTimelineProps) {
+ const days = useMemo(() => {
+ const map = new Map()
+ for (const row of rows) {
+ const key = dayKey(row.createdAt)
+ const existing = map.get(key)
+ if (existing) {
+ existing.events.push(row)
+ } else {
+ map.set(key, { key, label: dayLabel(row.createdAt), events: [row] })
+ }
+ }
+ return Array.from(map.values())
+ }, [rows])
+
+ return (
+
+ {days.map((day, dayIndex) => (
+
+
+ {day.label}
+
+
+ {day.events.map((event, index) => (
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/apps/web/src/components/domain/audit-view-toggle.tsx b/apps/web/src/components/domain/audit-view-toggle.tsx
new file mode 100644
index 0000000..45baed8
--- /dev/null
+++ b/apps/web/src/components/domain/audit-view-toggle.tsx
@@ -0,0 +1,35 @@
+import { HistoryIcon, TableIcon } from 'lucide-react'
+
+import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
+
+export type AuditViewMode = 'timeline' | 'table'
+
+interface AuditViewToggleProps {
+ view: AuditViewMode
+ onViewChange: (view: AuditViewMode) => void
+}
+
+export function AuditViewToggle({ view, onViewChange }: AuditViewToggleProps) {
+ return (
+ {
+ const selected = next[0]
+ if (selected === 'timeline' || selected === 'table') onViewChange(selected)
+ }}
+ aria-label="Вид журнала"
+ >
+
+
+ Лента
+
+
+
+ Таблица
+
+
+ )
+}
diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx
index 174cb7c..bc19612 100644
--- a/apps/web/src/components/layout/system-monitor-popover.tsx
+++ b/apps/web/src/components/layout/system-monitor-popover.tsx
@@ -72,6 +72,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
)
}
+type SyncStatusRow = {
+ accountId?: string
+ status?: string | null
+ ok?: boolean
+}
+
function isStaleSync(lastAt: string | null | undefined): boolean {
if (!lastAt) return true
const ts = new Date(lastAt).getTime()
@@ -79,13 +85,31 @@ function isStaleSync(lastAt: string | null | undefined): boolean {
return Date.now() - ts > 24 * 60 * 60 * 1000
}
+function isSyncFailureStatus(row: SyncStatusRow): boolean {
+ const status = String(row.status ?? '').toLowerCase()
+ return status === 'failed' || status === 'error' || row.ok === false
+}
+
+/** Последний синк на аккаунт (журнал desc по startedAt); старые fail после OK игнорируются. */
+function countCurrentSyncFailures(rows: SyncStatusRow[]): number {
+ const seen = new Set()
+ let failed = 0
+ for (const row of rows) {
+ const accountId = row.accountId
+ if (!accountId || seen.has(accountId)) continue
+ seen.add(accountId)
+ if (isSyncFailureStatus(row)) failed += 1
+ }
+ return failed
+}
+
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
export function SystemMonitorPopover() {
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
const syncQ = useQuery({
queryKey: ['sync', 'status'],
- queryFn: () => api.fetchSyncStatus() as Promise>,
+ queryFn: () => api.fetchSyncStatus() as Promise,
refetchInterval: 30_000,
})
const notifyQ = useQuery({
@@ -106,12 +130,8 @@ export function SystemMonitorPopover() {
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
const staleSync =
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
- const recentSyncFailed = (syncQ.data ?? []).some(
- (row) =>
- String(row.status ?? '').toLowerCase() === 'failed' ||
- String(row.status ?? '').toLowerCase() === 'error' ||
- row.ok === false,
- )
+ const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
+ const recentSyncFailed = failedSyncCount > 0
const syncAlert = staleSync || recentSyncFailed
const failedNotifications = (notifyQ.data ?? []).filter(
(n) => String(n.status ?? '').toLowerCase() === 'failed',
@@ -123,7 +143,7 @@ export function SystemMonitorPopover() {
{
id: 'sync',
label: 'Синк',
- value: syncAlert ? '!' : 'OK',
+ value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
unit: '',
percent: syncAlert ? 35 : 100,
icon: ,
@@ -164,7 +184,7 @@ export function SystemMonitorPopover() {
alert: downCount > 0,
},
],
- [downCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
+ [downCount, failedSyncCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
)
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
diff --git a/apps/web/src/components/reui/timeline.tsx b/apps/web/src/components/reui/timeline.tsx
new file mode 100644
index 0000000..5b383de
--- /dev/null
+++ b/apps/web/src/components/reui/timeline.tsx
@@ -0,0 +1,256 @@
+import { createContext, useCallback, useContext, useState } from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+
+import { cn } from "@cfdm/ui/lib/utils"
+
+// Types
+type TimelineContextValue = {
+ activeStep: number
+ setActiveStep: (step: number) => void
+}
+
+// Context
+const TimelineContext = createContext(
+ undefined
+)
+
+const useTimeline = () => {
+ const context = useContext(TimelineContext)
+ if (!context) {
+ throw new Error("useTimeline must be used within a Timeline")
+ }
+ return context
+}
+
+// Components
+interface TimelineProps extends useRender.ComponentProps<"div"> {
+ defaultValue?: number
+ value?: number
+ onValueChange?: (value: number) => void
+ orientation?: "horizontal" | "vertical"
+}
+
+function Timeline({
+ defaultValue = 1,
+ value,
+ onValueChange,
+ orientation = "vertical",
+ className,
+ render,
+ children,
+ ...props
+}: TimelineProps) {
+ const [activeStep, setInternalStep] = useState(defaultValue)
+
+ const setActiveStep = useCallback(
+ (step: number) => {
+ if (value === undefined) {
+ setInternalStep(step)
+ }
+ onValueChange?.(step)
+ },
+ [value, onValueChange]
+ )
+
+ const currentStep = value ?? activeStep
+
+ const defaultProps = {
+ className: cn(
+ "group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
+ className
+ ),
+ "data-orientation": orientation,
+ "data-slot": "timeline",
+ children,
+ }
+
+ return (
+
+ {useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })}
+
+ )
+}
+
+// TimelineContent
+function TimelineContent({
+ className,
+ render,
+ children,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn("text-muted-foreground text-sm", className),
+ "data-slot": "timeline-content",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })
+}
+
+// TimelineDate
+type TimelineDateProps = useRender.ComponentProps<"time">
+
+function TimelineDate({
+ className,
+ render,
+ children,
+ ...props
+}: TimelineDateProps) {
+ const defaultProps = {
+ className: cn(
+ "mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
+ className
+ ),
+ "data-slot": "timeline-date",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "time",
+ render,
+ props: mergeProps<"time">(defaultProps, props),
+ })
+}
+
+// TimelineHeader
+function TimelineHeader({
+ className,
+ render,
+ children,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn(className),
+ "data-slot": "timeline-header",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })
+}
+
+// TimelineIndicator
+type TimelineIndicatorProps = useRender.ComponentProps<"div">
+
+function TimelineIndicator({
+ className,
+ children,
+ render,
+ ...props
+}: TimelineIndicatorProps) {
+ const defaultProps = {
+ "aria-hidden": true,
+ className: cn(
+ "group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
+ className
+ ),
+ "data-slot": "timeline-indicator",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })
+}
+
+// TimelineItem
+interface TimelineItemProps extends useRender.ComponentProps<"div"> {
+ step: number
+}
+
+function TimelineItem({
+ step,
+ className,
+ render,
+ children,
+ ...props
+}: TimelineItemProps) {
+ const { activeStep } = useTimeline()
+
+ const defaultProps = {
+ className: cn(
+ "group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
+ className
+ ),
+ "data-completed": step <= activeStep || undefined,
+ "data-slot": "timeline-item",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })
+}
+
+// TimelineSeparator
+function TimelineSeparator({
+ className,
+ render,
+ children,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ "aria-hidden": true,
+ className: cn(
+ "group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
+ className
+ ),
+ "data-slot": "timeline-separator",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "div",
+ render,
+ props: mergeProps<"div">(defaultProps, props),
+ })
+}
+
+// TimelineTitle
+function TimelineTitle({
+ className,
+ render,
+ children,
+ ...props
+}: useRender.ComponentProps<"h3">) {
+ const defaultProps = {
+ className: cn("font-medium text-sm", className),
+ "data-slot": "timeline-title",
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "h3",
+ render,
+ props: mergeProps<"h3">(defaultProps, props),
+ })
+}
+
+export {
+ Timeline,
+ TimelineContent,
+ TimelineDate,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineSeparator,
+ TimelineTitle,
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index b093cc3..ad0f505 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -392,6 +392,7 @@ export const api = {
entityId: string
action: string
diff: Record | null
+ actorUserId?: string | null
createdAt: string
}>>(`/api/audit?limit=${limit}`),
}
diff --git a/apps/web/src/routes/_auth/audit.tsx b/apps/web/src/routes/_auth/audit.tsx
index 7ef806f..c8e570e 100644
--- a/apps/web/src/routes/_auth/audit.tsx
+++ b/apps/web/src/routes/_auth/audit.tsx
@@ -1,6 +1,8 @@
-import { createFileRoute, Link } from '@tanstack/react-router'
+import { useMemo } from 'react'
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
-import { HistoryIcon } from 'lucide-react'
+import { HistoryIcon, UserRoundIcon } from 'lucide-react'
+import { z } from 'zod'
import { api } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
@@ -8,49 +10,91 @@ import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
+import { AuditTimeline, type AuditRow } from '@/components/domain/audit-timeline'
+import { AuditViewToggle, type AuditViewMode } from '@/components/domain/audit-view-toggle'
+import {
+ ACTION_LABELS,
+ auditActionBadgeVariant,
+ auditActionLabel,
+ auditEntityLabel,
+ diffPreview,
+} from '@/components/domain/audit-labels'
import { Button } from '@cfdm/ui/components/button'
-import { Badge } from '@cfdm/ui/components/badge'
+import { Badge } from '@/components/reui/badge'
+import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { TableSkeleton } from '@/components/skeletons'
-const AUDIT_ENTITY_LABELS: Record = {
- vps: 'VPS',
- payment: 'Платёж',
- providerAccount: 'Аккаунт',
- provider: 'Хостер',
- settings: 'Настройки',
- balanceLedger: 'Баланс',
- serverProject: 'Проект',
-}
+const auditSearchSchema = z.object({
+ view: z.enum(['timeline', 'table']).optional(),
+ action: z.enum(['all', 'create', 'update', 'delete']).optional(),
+})
-function auditEntityLabel(entity: string): string {
- return AUDIT_ENTITY_LABELS[entity] ?? entity
-}
+type AuditActionFilter = 'all' | 'create' | 'update' | 'delete'
-interface AuditRow {
- id: string
- entity: string
- entityId: string
- action: string
- diff: Record | null
- createdAt: string
-}
-
-const ACTION_LABELS: Record = {
- create: 'Создание',
- update: 'Изменение',
- delete: 'Удаление',
-}
+const ACTION_FILTERS: { value: AuditActionFilter; label: string }[] = [
+ { value: 'all', label: 'Все' },
+ { value: 'create', label: ACTION_LABELS.create },
+ { value: 'update', label: ACTION_LABELS.update },
+ { value: 'delete', label: ACTION_LABELS.delete },
+]
export const Route = createFileRoute('/_auth/audit')({
+ validateSearch: (search) => auditSearchSchema.parse(search),
component: AuditPage,
})
function AuditPage() {
+ const navigate = useNavigate({ from: Route.fullPath })
+ const search = Route.useSearch()
+ const view: AuditViewMode = search.view === 'table' ? 'table' : 'timeline'
+ const actionFilter: AuditActionFilter = search.action ?? 'all'
+
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['audit'],
- queryFn: () => api.fetchAuditLog(200),
+ queryFn: () => api.fetchAuditLog(200) as Promise,
})
+ const filtered = useMemo(() => {
+ const rows = data ?? []
+ if (actionFilter === 'all') return rows
+ return rows.filter((r) => r.action === actionFilter)
+ }, [actionFilter, data])
+
+ const actionCounts = useMemo(() => {
+ const rows = data ?? []
+ const counts: Record = {
+ all: rows.length,
+ create: 0,
+ update: 0,
+ delete: 0,
+ }
+ for (const r of rows) {
+ if (r.action === 'create' || r.action === 'update' || r.action === 'delete') {
+ counts[r.action] += 1
+ }
+ }
+ return counts
+ }, [data])
+
+ const setView = (next: AuditViewMode) => {
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ view: next === 'timeline' ? undefined : next,
+ }),
+ })
+ }
+
+ const setActionFilter = (next: string) => {
+ if (next !== 'all' && next !== 'create' && next !== 'update' && next !== 'delete') return
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ action: next === 'all' ? undefined : next,
+ }),
+ })
+ }
+
const columns: DataGridColumn[] = [
{
key: 'createdAt',
@@ -58,40 +102,57 @@ function AuditPage() {
icon: HistoryIcon,
sortValue: (r) => r.createdAt,
cell: (r) => (
-
+
{new Date(r.createdAt).toLocaleString('ru-RU')}
),
},
+ {
+ key: 'action',
+ header: 'Действие',
+ cell: (r) => (
+
+ {auditActionLabel(r.action)}
+
+ ),
+ },
{
key: 'entity',
header: 'Сущность',
cell: (r) => {auditEntityLabel(r.entity)} ,
},
- {
- key: 'action',
- header: 'Действие',
- cell: (r) => ACTION_LABELS[r.action] ?? r.action,
- },
{
key: 'entityId',
header: 'ID',
cell: (r) =>
r.entity === 'vps' ? (
- }>
+ }
+ >
{r.entityId}
) : (
{r.entityId}
),
},
+ {
+ key: 'actor',
+ header: 'Актор',
+ icon: UserRoundIcon,
+ sortValue: (r) => r.actorUserId ?? '',
+ cell: (r) => (
+ {r.actorUserId?.trim() || 'система'}
+ ),
+ },
{
key: 'diff',
header: 'Изменения',
sortable: false,
cell: (r) => (
-
- {r.diff ? JSON.stringify(r.diff) : '—'}
+
+ {diffPreview(r.diff)}
),
},
@@ -99,7 +160,29 @@ function AuditPage() {
return (
-
+ }
+ />
+
+
+
+ {ACTION_FILTERS.map((option) => (
+
+ {option.label}
+
+ {actionCounts[option.value]}
+
+
+ ))}
+
+
+
}
empty={!data?.length}
emptyTitle="Записей нет"
- emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
+ emptyDescription="Изменения появятся здесь после CRUD-операций"
>
- {(rows) => (
- r.id}
- pageSize={25}
- />
- )}
+ {() => {
+ if (filtered.length === 0) {
+ return (
+
+
Нет записей для фильтра «{ACTION_LABELS[actionFilter] ?? actionFilter}»
+
setActionFilter('all')}>
+ Сбросить фильтр
+
+
+ )
+ }
+
+ if (view === 'table') {
+ return (
+ r.id}
+ pageSize={25}
+ dense
+ />
+ )
+ }
+
+ return
+ }}
)
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
index ba291ec..6e64aef 100644
--- a/packages/ui/src/components/avatar.tsx
+++ b/packages/ui/src/components/avatar.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx
new file mode 100644
index 0000000..8a00e1c
--- /dev/null
+++ b/packages/ui/src/components/toggle-group.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
+import { type VariantProps } from "class-variance-authority"
+
+import { cn } from "@cfdm/ui/lib/utils"
+import { toggleVariants } from "@cfdm/ui/components/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 2,
+ orientation: "horizontal",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 2,
+ orientation = "horizontal",
+ children,
+ ...props
+}: ToggleGroupPrimitive.Props &
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx
new file mode 100644
index 0000000..cd14f01
--- /dev/null
+++ b/packages/ui/src/components/toggle.tsx
@@ -0,0 +1,43 @@
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@cfdm/ui/lib/utils"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }