diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index 0863936..f836685 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -25,14 +25,29 @@ export function TokenSync() { const redirecting = useRef(false) // 1) Token sync — single update path к accessToken holder в client.ts. + // ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token + // не expired. Раньше передавали raw {@code auth.user?.access_token} — это + // включало случаи когда oidc-client-ts вернул user object с уже expired + // token'ом (silent renew failed но user object всё ещё в state). axios + // отправлял Bearer expired_token → backend (с permissive=false для guest + // anyway re-validates JWT через oauth2ResourceServer) → 401 → catalog UI + // показывал «Не удалось загрузить данные» вместо PUBLIC dicts. + // + // user.expired property из oidc-client-ts: true если access_token expiry + // прошла. Дополнительно проверяем isAuthenticated — react-oidc-context + // обновляет это в реальном времени. useEffect(() => { - setAccessToken(auth.user?.access_token ?? null) + const liveToken = + auth.isAuthenticated && auth.user && !auth.user.expired + ? auth.user.access_token + : null + setAccessToken(liveToken ?? null) // Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade). // Идемпотентный no-op если ключа нет. if (typeof window !== 'undefined') { window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY) } - }, [auth.user?.access_token]) + }, [auth.isAuthenticated, auth.user, auth.user?.access_token, auth.user?.expired]) // 2) 401 handler — registered once на app mount, делегирует silent renew / // redirect на Keycloak. Guard'ы: diff --git a/ordinis-admin-ui/src/ui/components/alert.tsx b/ordinis-admin-ui/src/ui/components/alert.tsx new file mode 100644 index 0000000..fdae404 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/alert.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +/** + * Alert / inline message — статусная карточка с border-left accent. + * shadcn-style API: children может быть [Title, Description] или произвольный. + */ +const alertVariants = cva( + 'relative w-full rounded-md border-l-4 px-4 py-3 text-sm [&>svg]:text-ink-2 [&>svg]:size-4 [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-3 [&>svg+*]:pl-6', + { + variants: { + variant: { + info: 'border-l-accent bg-accent-bg text-ink', + success: 'border-l-green bg-green-bg text-ink', + warning: 'border-l-warn bg-warn-bg text-ink', + danger: 'border-l-pink bg-pink-bg text-ink', + neutral: 'border-l-line bg-surface-2 text-ink', + }, + }, + defaultVariants: { variant: 'info' }, + }, +) + +export type AlertProps = React.HTMLAttributes & + VariantProps & { + title?: React.ReactNode + } + +export const Alert = React.forwardRef( + function Alert({ className, variant, title, children, ...props }, ref) { + return ( +
+ {title &&
{title}
} +
{children}
+
+ ) + }, +) diff --git a/ordinis-admin-ui/src/ui/components/badge.tsx b/ordinis-admin-ui/src/ui/components/badge.tsx new file mode 100644 index 0000000..86e7f3f --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/badge.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-2xs font-medium font-mono uppercase tracking-wide whitespace-nowrap', + { + variants: { + variant: { + default: 'bg-surface-2 text-ink-2 border border-line', + accent: 'bg-accent-bg text-accent', + success: 'bg-green-bg text-green', + warning: 'bg-warn-bg text-warn', + danger: 'bg-pink-bg text-pink', + outline: 'border border-line text-ink-2', + }, + }, + defaultVariants: { variant: 'default' }, + }, +) + +export type BadgeProps = React.HTMLAttributes & + VariantProps + +export function Badge({ className, variant, ...props }: BadgeProps) { + return +} diff --git a/ordinis-admin-ui/src/ui/components/button.tsx b/ordinis-admin-ui/src/ui/components/button.tsx new file mode 100644 index 0000000..fe693af --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/button.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +/** + * Button — shadcn-стиль, адаптирован под наш token palette. + * + * Variants per design handoff: + * - primary — navy bg + on-accent text (deep coffee → cream). + * - secondary — surface bg + ink text + line border (default outlined). + * - ghost — transparent + hover:surface-2. + * - danger — pink bg + on-accent text (destructive actions). + * - link — accent text + underline-offset, no bg. + * + * Sizes: sm (28px), md (36px default), lg (40px), icon-sm (24px square), + * icon (32px square). + * + * Use {@code asChild} (Radix Slot pattern) для рендера кастомного root + * элемента — например {@code }. + */ +const buttonVariants = cva( + [ + 'inline-flex items-center justify-center gap-2 whitespace-nowrap', + 'rounded-md text-sm font-medium transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg', + 'disabled:pointer-events-none disabled:opacity-50', + '[&_svg]:pointer-events-none [&_svg]:shrink-0', + ].join(' '), + { + variants: { + variant: { + primary: + 'bg-navy text-on-accent hover:opacity-90 active:opacity-95', + secondary: + 'border border-line bg-surface text-ink hover:bg-surface-2', + ghost: + 'text-ink hover:bg-surface-2', + danger: + 'bg-pink text-on-accent hover:opacity-90 active:opacity-95', + link: + 'text-accent underline-offset-4 hover:underline', + }, + size: { + sm: 'h-7 px-2.5 text-xs', + md: 'h-9 px-3.5', + lg: 'h-10 px-4', + 'icon-sm': 'h-6 w-6', + icon: 'h-8 w-8', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +) + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps & { + asChild?: boolean + leftIcon?: React.ReactNode + rightIcon?: React.ReactNode + } + +export const Button = React.forwardRef( + function Button( + { className, variant, size, asChild, leftIcon, rightIcon, children, ...props }, + ref, + ) { + const Comp = asChild ? Slot : 'button' + return ( + + {leftIcon} + {children} + {rightIcon} + + ) + }, +) + +export { buttonVariants } diff --git a/ordinis-admin-ui/src/ui/components/cap.tsx b/ordinis-admin-ui/src/ui/components/cap.tsx new file mode 100644 index 0000000..147b348 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/cap.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +/** + * Cap — Tektur uppercase caption (handoff design system primitive). + * 10.5px / 600 / letter-spacing 0.10em / mute color. + * + * Use для section labels, table headers (cap variant), toolbar group titles. + */ +export type CapProps = React.HTMLAttributes + +export function Cap({ className, ...props }: CapProps) { + return ( + + ) +} diff --git a/ordinis-admin-ui/src/ui/components/card.tsx b/ordinis-admin-ui/src/ui/components/card.tsx new file mode 100644 index 0000000..7c26b08 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/card.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +/** + * Card / Panel — surface block с radius-card, border-line. Замена для + * {@code @nstart/ui} Panel. Используем сложно через {@link CardHeader}, + * {@link CardContent}, {@link CardFooter}. + */ +export const Card = React.forwardRef>( + function Card({ className, ...props }, ref) { + return ( +
+ ) + }, +) + +export const CardHeader = React.forwardRef>( + function CardHeader({ className, ...props }, ref) { + return
+ }, +) + +export const CardTitle = React.forwardRef>( + function CardTitle({ className, ...props }, ref) { + return ( +

+ ) + }, +) + +export const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(function CardDescription({ className, ...props }, ref) { + return

+}) + +export const CardContent = React.forwardRef>( + function CardContent({ className, ...props }, ref) { + return

+ }, +) + +export const CardFooter = React.forwardRef>( + function CardFooter({ className, ...props }, ref) { + return
+ }, +) diff --git a/ordinis-admin-ui/src/ui/components/checkbox.tsx b/ordinis-admin-ui/src/ui/components/checkbox.tsx new file mode 100644 index 0000000..0d44ce5 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/checkbox.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check, Minus } from 'lucide-react' +import { cn } from '@/lib/utils' + +/** + * Checkbox — Radix primitive с indeterminate support через {@code checked='indeterminate'}. + * Решает старый bug @nstart/ui где indeterminate не sync'ался через ref. + */ +export const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function Checkbox({ className, ...props }, ref) { + return ( + + + {props.checked === 'indeterminate' ? ( + + ) : ( + + )} + + + ) +}) diff --git a/ordinis-admin-ui/src/ui/components/dialog.tsx b/ordinis-admin-ui/src/ui/components/dialog.tsx new file mode 100644 index 0000000..2a15782 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/dialog.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +/** + * Radix Dialog — modal в shadcn-стиле. Заменяет {@code @nstart/ui} Modal. + * + * Использование: + *
+ * 
+ *   
+ *     
+ *       Создать запись
+ *       Описание формы
+ *     
+ *     
+ * + * + * + * + * + *
+ *
+ * + * Default size — 560px (handoff Create-dict). Variants: sm 440px, md 560px, + * lg 720px, xl 880px, full (mobile fullscreen). + */ +export const Dialog = DialogPrimitive.Root +export const DialogTrigger = DialogPrimitive.Trigger +export const DialogPortal = DialogPrimitive.Portal +export const DialogClose = DialogPrimitive.Close + +export const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DialogOverlay({ className, ...props }, ref) { + return ( + + ) +}) + +const SIZE_CLASSES: Record = { + sm: 'max-w-[440px]', + md: 'max-w-[560px]', + lg: 'max-w-[720px]', + xl: 'max-w-[880px]', + full: 'max-w-full max-h-full sm:max-w-[920px]', +} + +export type DialogContentProps = React.ComponentPropsWithoutRef & { + size?: keyof typeof SIZE_CLASSES + hideClose?: boolean +} + +export const DialogContent = React.forwardRef< + React.ElementRef, + DialogContentProps +>(function DialogContent({ className, children, size = 'md', hideClose, ...props }, ref) { + return ( + + + + {children} + {!hideClose && ( + + + + )} + + + ) +}) + +export function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
+} + +export function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +export const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DialogTitle({ className, ...props }, ref) { + return ( + + ) +}) + +export const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DialogDescription({ className, ...props }, ref) { + return ( + + ) +}) diff --git a/ordinis-admin-ui/src/ui/components/dropdown-menu.tsx b/ordinis-admin-ui/src/ui/components/dropdown-menu.tsx new file mode 100644 index 0000000..19b207b --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/dropdown-menu.tsx @@ -0,0 +1,162 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { Check, ChevronRight, Circle } from 'lucide-react' +import { cn } from '@/lib/utils' + +export const DropdownMenu = DropdownMenuPrimitive.Root +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +export const DropdownMenuGroup = DropdownMenuPrimitive.Group +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal +export const DropdownMenuSub = DropdownMenuPrimitive.Sub +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +export const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(function DropdownMenuSubTrigger({ className, inset, children, ...props }, ref) { + return ( + + {children} + + + ) +}) + +export const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DropdownMenuSubContent({ className, ...props }, ref) { + return ( + + ) +}) + +export const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DropdownMenuContent({ className, sideOffset = 4, ...props }, ref) { + return ( + + + + ) +}) + +export const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(function DropdownMenuItem({ className, inset, ...props }, ref) { + return ( + + ) +}) + +export const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DropdownMenuCheckboxItem({ className, children, checked, ...props }, ref) { + return ( + + + + + + + {children} + + ) +}) + +export const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DropdownMenuRadioItem({ className, children, ...props }, ref) { + return ( + + + + + + + {children} + + ) +}) + +export const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(function DropdownMenuLabel({ className, inset, ...props }, ref) { + return ( + + ) +}) + +export const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(function DropdownMenuSeparator({ className, ...props }, ref) { + return ( + + ) +}) diff --git a/ordinis-admin-ui/src/ui/components/empty-state.tsx b/ordinis-admin-ui/src/ui/components/empty-state.tsx new file mode 100644 index 0000000..5cf4697 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/empty-state.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export type EmptyStateProps = React.HTMLAttributes & { + icon?: React.ReactNode + title: React.ReactNode + description?: React.ReactNode + action?: React.ReactNode +} + +export function EmptyState({ + className, + icon, + title, + description, + action, + ...props +}: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +
+
{title}
+ {description &&
{description}
} +
+ {action &&
{action}
} +
+ ) +} diff --git a/ordinis-admin-ui/src/ui/components/form-actions.tsx b/ordinis-admin-ui/src/ui/components/form-actions.tsx new file mode 100644 index 0000000..90de412 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/form-actions.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +/** + * FormActions — фиксированная row кнопок [Cancel | Submit] в footer форм. + * Reverse на mobile — primary action на bottom (proper UX). + */ +export type FormActionsProps = React.HTMLAttributes + +export function FormActions({ className, children, ...props }: FormActionsProps) { + return ( +
+ {children} +
+ ) +} diff --git a/ordinis-admin-ui/src/ui/components/icon-button.tsx b/ordinis-admin-ui/src/ui/components/icon-button.tsx new file mode 100644 index 0000000..3d9451f --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/icon-button.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +/** + * IconButton — square button с иконкой. Заменяет {@code @nstart/ui} IconButton. + * Variants: default (ghost), accent (primary fill), danger (destructive). + */ +const iconButtonVariants = cva( + [ + 'inline-flex items-center justify-center rounded-md transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', + 'disabled:pointer-events-none disabled:opacity-50', + ].join(' '), + { + variants: { + variant: { + default: 'text-ink-2 hover:text-ink hover:bg-surface-2', + accent: 'bg-accent text-on-accent hover:opacity-90', + danger: 'text-pink hover:bg-pink-bg', + }, + size: { + sm: 'h-6 w-6 [&_svg]:size-3.5', + md: 'h-8 w-8 [&_svg]:size-4', + lg: 'h-10 w-10 [&_svg]:size-5', + }, + }, + defaultVariants: { variant: 'default', size: 'md' }, + }, +) + +export type IconButtonProps = React.ButtonHTMLAttributes & + VariantProps & { + /** Required для accessibility — иконки без текста должны иметь label. */ + label: string + icon: React.ReactNode + } + +export const IconButton = React.forwardRef( + function IconButton({ className, variant, size, label, icon, ...props }, ref) { + return ( + + ) + }, +) diff --git a/ordinis-admin-ui/src/ui/components/input.tsx b/ordinis-admin-ui/src/ui/components/input.tsx new file mode 100644 index 0000000..bcb7d43 --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/input.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +/** + * Text input — single-line. Использует наши tokens, focus-ring через accent. + * + * Wrap'итесь в {@code