FlotillasGPS - Sistema completo de monitoreo de flotillas GPS
Sistema completo para monitoreo y gestion de flotas de vehiculos con: - Backend FastAPI con PostgreSQL/TimescaleDB - Frontend React con TypeScript y TailwindCSS - App movil React Native con Expo - Soporte para dispositivos GPS, Meshtastic y celulares - Video streaming en vivo con MediaMTX - Geocercas, alertas, viajes y reportes - Autenticacion JWT y WebSockets en tiempo real Documentacion completa y guias de usuario incluidas.
This commit is contained in:
185
frontend/src/components/ui/Badge.tsx
Normal file
185
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type BadgeVariant =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'info'
|
||||
|
||||
export type BadgeSize = 'xs' | 'sm' | 'md'
|
||||
|
||||
export interface BadgeProps {
|
||||
children: ReactNode
|
||||
variant?: BadgeVariant
|
||||
size?: BadgeSize
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: 'bg-slate-700 text-slate-300',
|
||||
primary: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||
success: 'bg-success-500/20 text-success-400 border border-success-500/30',
|
||||
warning: 'bg-warning-500/20 text-warning-400 border border-warning-500/30',
|
||||
error: 'bg-error-500/20 text-error-400 border border-error-500/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
}
|
||||
|
||||
const dotStyles: Record<BadgeVariant, string> = {
|
||||
default: 'bg-slate-400',
|
||||
primary: 'bg-accent-500',
|
||||
success: 'bg-success-500',
|
||||
warning: 'bg-warning-500',
|
||||
error: 'bg-error-500',
|
||||
info: 'bg-blue-500',
|
||||
}
|
||||
|
||||
const sizeStyles: Record<BadgeSize, string> = {
|
||||
xs: 'px-1.5 py-0.5 text-xs',
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
}
|
||||
|
||||
export default function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
dot = false,
|
||||
pulse = false,
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 font-medium rounded-full',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
{pulse && (
|
||||
<span
|
||||
className={clsx(
|
||||
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
|
||||
dotStyles[variant]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'relative inline-flex rounded-full h-2 w-2',
|
||||
dotStyles[variant]
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Status badge for vehiculos/dispositivos
|
||||
export type StatusType = 'online' | 'offline' | 'warning' | 'error' | 'idle'
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: StatusType
|
||||
label?: string
|
||||
size?: BadgeSize
|
||||
showDot?: boolean
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
StatusType,
|
||||
{ variant: BadgeVariant; label: string }
|
||||
> = {
|
||||
online: { variant: 'success', label: 'En linea' },
|
||||
offline: { variant: 'default', label: 'Sin conexion' },
|
||||
warning: { variant: 'warning', label: 'Advertencia' },
|
||||
error: { variant: 'error', label: 'Error' },
|
||||
idle: { variant: 'info', label: 'Inactivo' },
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
size = 'sm',
|
||||
showDot = true,
|
||||
}: StatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
size={size}
|
||||
dot={showDot}
|
||||
pulse={status === 'online'}
|
||||
>
|
||||
{label || config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Priority badge for alertas
|
||||
export type PriorityType = 'baja' | 'media' | 'alta' | 'critica'
|
||||
|
||||
export interface PriorityBadgeProps {
|
||||
priority: PriorityType
|
||||
size?: BadgeSize
|
||||
}
|
||||
|
||||
const priorityConfig: Record<PriorityType, { variant: BadgeVariant; label: string }> = {
|
||||
baja: { variant: 'info', label: 'Baja' },
|
||||
media: { variant: 'warning', label: 'Media' },
|
||||
alta: { variant: 'error', label: 'Alta' },
|
||||
critica: { variant: 'error', label: 'Critica' },
|
||||
}
|
||||
|
||||
export function PriorityBadge({ priority, size = 'sm' }: PriorityBadgeProps) {
|
||||
const config = priorityConfig[priority]
|
||||
return (
|
||||
<Badge variant={config.variant} size={size} dot={priority === 'critica'} pulse={priority === 'critica'}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Counter badge (for notifications, alerts, etc.)
|
||||
export interface CounterBadgeProps {
|
||||
count: number
|
||||
max?: number
|
||||
variant?: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CounterBadge({
|
||||
count,
|
||||
max = 99,
|
||||
variant = 'error',
|
||||
className,
|
||||
}: CounterBadgeProps) {
|
||||
if (count === 0) return null
|
||||
|
||||
const displayCount = count > max ? `${max}+` : count.toString()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5',
|
||||
'text-xs font-bold rounded-full',
|
||||
variant === 'error' && 'bg-error-500 text-white',
|
||||
variant === 'warning' && 'bg-warning-500 text-black',
|
||||
variant === 'success' && 'bg-success-500 text-white',
|
||||
variant === 'primary' && 'bg-accent-500 text-white',
|
||||
variant === 'default' && 'bg-slate-600 text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/ui/Button.tsx
Normal file
105
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost' | 'success'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'bg-accent-500 text-white hover:bg-accent-600 focus:ring-accent-500 shadow-lg shadow-accent-500/25',
|
||||
secondary:
|
||||
'bg-slate-700 text-white hover:bg-slate-600 focus:ring-slate-500',
|
||||
danger:
|
||||
'bg-error-500 text-white hover:bg-error-600 focus:ring-error-500 shadow-lg shadow-error-500/25',
|
||||
outline:
|
||||
'border border-slate-600 text-slate-300 hover:bg-slate-800 hover:border-slate-500 focus:ring-slate-500',
|
||||
ghost:
|
||||
'text-slate-300 hover:bg-slate-800 hover:text-white focus:ring-slate-500',
|
||||
success:
|
||||
'bg-success-500 text-white hover:bg-success-600 focus:ring-success-500 shadow-lg shadow-success-500/25',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
xs: 'px-2.5 py-1 text-xs',
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Cargando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
94
frontend/src/components/ui/Card.tsx
Normal file
94
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ReactNode, HTMLAttributes, forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, children, padding = 'md', hover = false, glow = false, ...props }, ref) => {
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'bg-card rounded-xl border border-slate-700/50',
|
||||
paddingStyles[padding],
|
||||
hover && 'hover:bg-card-hover hover:border-slate-600/50 transition-all duration-200 cursor-pointer',
|
||||
glow && 'shadow-glow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Card.displayName = 'Card'
|
||||
|
||||
// Card Header
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action, className, ...props }: CardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('flex items-start justify-between mb-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card Content
|
||||
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CardContent({ children, className, ...props }: CardContentProps) {
|
||||
return (
|
||||
<div className={clsx('', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card Footer
|
||||
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className, ...props }: CardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-4 pt-4 border-t border-slate-700/50 flex items-center justify-end gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
171
frontend/src/components/ui/Checkbox.tsx
Normal file
171
frontend/src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string | ReactNode
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, label, description, error, id, ...props }, ref) => {
|
||||
const checkboxId = id || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
className={clsx(
|
||||
'h-4 w-4 rounded border-slate-600 bg-background-800',
|
||||
'text-accent-500 focus:ring-accent-500 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'transition-colors duration-200 cursor-pointer',
|
||||
error && 'border-error-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{(label || description) && (
|
||||
<div className="ml-3">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className="text-sm font-medium text-slate-300 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-error-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Checkbox.displayName = 'Checkbox'
|
||||
|
||||
export default Checkbox
|
||||
|
||||
// Switch component
|
||||
export interface SwitchProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
}: SwitchProps) {
|
||||
const sizeStyles = {
|
||||
sm: {
|
||||
track: 'w-8 h-4',
|
||||
thumb: 'w-3 h-3',
|
||||
translate: 'translate-x-4',
|
||||
},
|
||||
md: {
|
||||
track: 'w-11 h-6',
|
||||
thumb: 'w-5 h-5',
|
||||
translate: 'translate-x-5',
|
||||
},
|
||||
lg: {
|
||||
track: 'w-14 h-7',
|
||||
thumb: 'w-6 h-6',
|
||||
translate: 'translate-x-7',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{(label || description) && (
|
||||
<div className="mr-4">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-slate-300">{label}</span>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex flex-shrink-0 rounded-full cursor-pointer',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
sizeStyles[size].track,
|
||||
checked ? 'bg-accent-500' : 'bg-slate-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none inline-block rounded-full bg-white shadow-lg',
|
||||
'transform ring-0 transition duration-200 ease-in-out',
|
||||
sizeStyles[size].thumb,
|
||||
checked ? sizeStyles[size].translate : 'translate-x-0.5',
|
||||
'mt-0.5 ml-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Radio button component
|
||||
export interface RadioProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const radioId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
id={radioId}
|
||||
className={clsx(
|
||||
'h-4 w-4 border-slate-600 bg-background-800',
|
||||
'text-accent-500 focus:ring-accent-500 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={radioId}
|
||||
className="ml-3 text-sm font-medium text-slate-300 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Radio.displayName = 'Radio'
|
||||
186
frontend/src/components/ui/Dropdown.tsx
Normal file
186
frontend/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface DropdownItem {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode
|
||||
items: DropdownItem[]
|
||||
align?: 'left' | 'right'
|
||||
width?: 'auto' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const widthStyles = {
|
||||
auto: 'w-auto min-w-[160px]',
|
||||
sm: 'w-40',
|
||||
md: 'w-48',
|
||||
lg: 'w-56',
|
||||
}
|
||||
|
||||
export default function Dropdown({
|
||||
trigger,
|
||||
items,
|
||||
align = 'right',
|
||||
width = 'auto',
|
||||
}: DropdownProps) {
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<Menu.Button as={Fragment}>{trigger}</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={clsx(
|
||||
'absolute z-20 mt-2 origin-top-right rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'divide-y divide-slate-700/50',
|
||||
'focus:outline-none',
|
||||
align === 'right' ? 'right-0' : 'left-0',
|
||||
widthStyles[width]
|
||||
)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return <div key={index} className="my-1 border-t border-slate-700" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item key={index} disabled={item.disabled}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 px-4 py-2 text-sm',
|
||||
'transition-colors duration-100',
|
||||
active && 'bg-slate-700',
|
||||
item.danger
|
||||
? 'text-error-400 hover:text-error-300'
|
||||
: 'text-slate-300 hover:text-white',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 w-5 h-5">{item.icon}</span>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// Dropdown button (with default styling)
|
||||
export interface DropdownButtonProps extends Omit<DropdownProps, 'trigger'> {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function DropdownButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
...props
|
||||
}: DropdownButtonProps) {
|
||||
const variantStyles = {
|
||||
primary: 'bg-accent-500 text-white hover:bg-accent-600',
|
||||
secondary: 'bg-slate-700 text-white hover:bg-slate-600',
|
||||
outline: 'border border-slate-600 text-slate-300 hover:bg-slate-800',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
{...props}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 rounded-lg font-medium',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Action menu (icon-only trigger)
|
||||
export interface ActionMenuProps extends Omit<DropdownProps, 'trigger'> {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function ActionMenu({ icon, ...props }: ActionMenuProps) {
|
||||
return (
|
||||
<Dropdown
|
||||
{...props}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'p-2 rounded-lg text-slate-400',
|
||||
'hover:text-white hover:bg-slate-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
{icon || (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/ui/Input.tsx
Normal file
134
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = true,
|
||||
type = 'text',
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-slate-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
||||
|
||||
// Textarea component
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{ className, label, error, helperText, fullWidth = true, id, ...props },
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200 resize-none',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-slate-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
193
frontend/src/components/ui/Modal.tsx
Normal file
193
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
showCloseButton?: boolean
|
||||
closeOnOverlayClick?: boolean
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={closeOnOverlayClick ? onClose : () => {}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Modal container */}
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={clsx(
|
||||
'w-full transform overflow-hidden rounded-2xl',
|
||||
'bg-card border border-slate-700/50 shadow-2xl',
|
||||
'transition-all',
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-start justify-between p-6 border-b border-slate-700/50">
|
||||
<div>
|
||||
{title && (
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-semibold text-white"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
{description && (
|
||||
<Dialog.Description className="mt-1 text-sm text-slate-400">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
// Modal Footer helper
|
||||
export interface ModalFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ModalFooter({ children, className }: ModalFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-end gap-3 mt-6 pt-6 border-t border-slate-700/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Confirm dialog shortcut
|
||||
export interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
variant = 'danger',
|
||||
isLoading = false,
|
||||
}: ConfirmModalProps) {
|
||||
const variantStyles = {
|
||||
danger: 'bg-error-500 hover:bg-error-600 focus:ring-error-500',
|
||||
warning: 'bg-warning-500 hover:bg-warning-600 focus:ring-warning-500',
|
||||
info: 'bg-accent-500 hover:bg-accent-600 focus:ring-accent-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-slate-300">{message}</p>
|
||||
<ModalFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantStyles[variant]
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Procesando...' : confirmText}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
186
frontend/src/components/ui/Select.tsx
Normal file
186
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
placeholder = 'Seleccionar...',
|
||||
error,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
}: SelectProps) {
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={clsx(
|
||||
'relative w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-left cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-2 truncate',
|
||||
!selectedOption && 'text-slate-500'
|
||||
)}
|
||||
>
|
||||
{selectedOption?.icon}
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={clsx(
|
||||
'absolute z-10 mt-1 w-full overflow-auto rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'max-h-60 py-1',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={({ active, selected }) =>
|
||||
clsx(
|
||||
'relative cursor-pointer select-none py-2.5 px-4',
|
||||
'transition-colors duration-100',
|
||||
active && 'bg-slate-700',
|
||||
selected && 'bg-accent-500/20',
|
||||
option.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-2 truncate',
|
||||
selected ? 'text-white font-medium' : 'text-slate-300'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</span>
|
||||
{selected && (
|
||||
<CheckIcon className="h-5 w-5 text-accent-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Native select for simpler use cases
|
||||
export interface NativeSelectProps
|
||||
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
options: SelectOption[]
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export function NativeSelect({
|
||||
label,
|
||||
error,
|
||||
options,
|
||||
fullWidth = true,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: NativeSelectProps) {
|
||||
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white appearance-none cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/ui/Skeleton.tsx
Normal file
140
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
variant?: 'text' | 'circular' | 'rectangular' | 'rounded'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
export default function Skeleton({
|
||||
className,
|
||||
variant = 'text',
|
||||
width,
|
||||
height,
|
||||
animate = true,
|
||||
}: SkeletonProps) {
|
||||
const baseStyles = 'bg-slate-700'
|
||||
|
||||
const variantStyles = {
|
||||
text: 'h-4 rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: '',
|
||||
rounded: 'rounded-lg',
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
animate && 'animate-shimmer',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for card
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton variant="text" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for table row
|
||||
export function SkeletonTableRow({ columns = 5 }: { columns?: number }) {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton variant="text" width={i === 0 ? '80%' : '60%'} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for list item
|
||||
export function SkeletonListItem() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton variant="text" width="70%" />
|
||||
<Skeleton variant="text" width="50%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for stats card
|
||||
export function SkeletonStats() {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<Skeleton variant="text" width="40%" height={14} className="mb-2" />
|
||||
<Skeleton variant="text" width="60%" height={32} className="mb-1" />
|
||||
<Skeleton variant="text" width="30%" height={14} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for chart
|
||||
export function SkeletonChart({ height = 200 }: { height?: number }) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
</div>
|
||||
<Skeleton variant="rounded" height={height} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for map
|
||||
export function SkeletonMap() {
|
||||
return (
|
||||
<div className="relative w-full h-full bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-slate-600 border-t-accent-500 rounded-full animate-spin mx-auto mb-4" />
|
||||
<Skeleton variant="text" width={120} className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for form
|
||||
export function SkeletonForm({ fields = 4 }: { fields?: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<Skeleton variant="text" width={100} height={14} className="mb-1.5" />
|
||||
<Skeleton variant="rounded" height={42} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
frontend/src/components/ui/Table.tsx
Normal file
312
frontend/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { ReactNode, useState, useMemo } from 'react'
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
width?: string
|
||||
sortable?: boolean
|
||||
render?: (item: T, index: number) => ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyExtractor: (item: T) => string
|
||||
onRowClick?: (item: T) => void
|
||||
selectedKey?: string
|
||||
emptyMessage?: string
|
||||
isLoading?: boolean
|
||||
loadingRows?: number
|
||||
|
||||
// Sorting
|
||||
sortable?: boolean
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
onSort?: (key: string, order: 'asc' | 'desc') => void
|
||||
|
||||
// Pagination
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
currentPage?: number
|
||||
totalItems?: number
|
||||
onPageChange?: (page: number) => void
|
||||
|
||||
// Styling
|
||||
compact?: boolean
|
||||
striped?: boolean
|
||||
hoverable?: boolean
|
||||
stickyHeader?: boolean
|
||||
}
|
||||
|
||||
export default function Table<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
selectedKey,
|
||||
emptyMessage = 'No hay datos disponibles',
|
||||
isLoading = false,
|
||||
loadingRows = 5,
|
||||
sortable = true,
|
||||
defaultSortKey,
|
||||
defaultSortOrder = 'asc',
|
||||
onSort,
|
||||
pagination = false,
|
||||
pageSize = 10,
|
||||
currentPage: controlledPage,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
compact = false,
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
stickyHeader = false,
|
||||
}: TableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState(defaultSortKey)
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(defaultSortOrder)
|
||||
const [internalPage, setInternalPage] = useState(1)
|
||||
|
||||
const currentPage = controlledPage ?? internalPage
|
||||
const setCurrentPage = onPageChange ?? setInternalPage
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (key: string) => {
|
||||
const newOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc'
|
||||
setSortKey(key)
|
||||
setSortOrder(newOrder)
|
||||
onSort?.(key, newOrder)
|
||||
}
|
||||
|
||||
// Sort data locally if no external handler
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey || onSort) return data
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = (a as Record<string, unknown>)[sortKey]
|
||||
const bVal = (b as Record<string, unknown>)[sortKey]
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
if (aVal === null || aVal === undefined) return 1
|
||||
if (bVal === null || bVal === undefined) return -1
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}, [data, sortKey, sortOrder, onSort])
|
||||
|
||||
// Pagination
|
||||
const total = totalItems ?? sortedData.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!pagination || onPageChange) return sortedData
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return sortedData.slice(start, start + pageSize)
|
||||
}, [sortedData, pagination, currentPage, pageSize, onPageChange])
|
||||
|
||||
// Cell padding based on compact mode
|
||||
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/50">
|
||||
<table className="min-w-full divide-y divide-slate-700/50">
|
||||
{/* Header */}
|
||||
<thead
|
||||
className={clsx(
|
||||
'bg-slate-800/50',
|
||||
stickyHeader && 'sticky top-0 z-10'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
'text-xs font-semibold text-slate-400 uppercase tracking-wider',
|
||||
'text-left',
|
||||
column.align === 'center' && 'text-center',
|
||||
column.align === 'right' && 'text-right',
|
||||
column.sortable !== false && sortable && 'cursor-pointer hover:text-white',
|
||||
column.width
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
onClick={() =>
|
||||
column.sortable !== false &&
|
||||
sortable &&
|
||||
handleSort(column.key)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{column.header}</span>
|
||||
{column.sortable !== false && sortable && (
|
||||
<span className="flex flex-col">
|
||||
<ChevronUpIcon
|
||||
className={clsx(
|
||||
'w-3 h-3 -mb-1',
|
||||
sortKey === column.key && sortOrder === 'asc'
|
||||
? 'text-accent-500'
|
||||
: 'text-slate-600'
|
||||
)}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-3 h-3 -mt-1',
|
||||
sortKey === column.key && sortOrder === 'desc'
|
||||
? 'text-accent-500'
|
||||
: 'text-slate-600'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Body */}
|
||||
<tbody className="divide-y divide-slate-700/30 bg-card">
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: loadingRows }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={cellPadding}>
|
||||
<div className="h-4 bg-slate-700 rounded animate-shimmer" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : paginatedData.length === 0 ? (
|
||||
// Empty state
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-4 py-12 text-center text-slate-500"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
// Data rows
|
||||
paginatedData.map((item, index) => {
|
||||
const key = keyExtractor(item)
|
||||
const isSelected = selectedKey === key
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={clsx(
|
||||
'transition-colors duration-100',
|
||||
striped && index % 2 === 1 && 'bg-slate-800/30',
|
||||
hoverable && 'hover:bg-slate-700/30',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isSelected && 'bg-accent-500/10 hover:bg-accent-500/20'
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
'text-sm text-slate-300',
|
||||
column.align === 'center' && 'text-center',
|
||||
column.align === 'right' && 'text-right'
|
||||
)}
|
||||
>
|
||||
{column.render
|
||||
? column.render(item, index)
|
||||
: String((item as Record<string, unknown>)[column.key] ?? '-')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-2">
|
||||
<p className="text-sm text-slate-500">
|
||||
Mostrando {(currentPage - 1) * pageSize + 1} -{' '}
|
||||
{Math.min(currentPage * pageSize, total)} de {total}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'hover:bg-slate-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum: number
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={clsx(
|
||||
'w-8 h-8 rounded-lg text-sm font-medium transition-colors',
|
||||
currentPage === pageNum
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-700 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'hover:bg-slate-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/components/ui/Tabs.tsx
Normal file
208
frontend/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { ReactNode, useState, createContext, useContext } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
badge?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
activeTab: string
|
||||
setActiveTab: (id: string) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
|
||||
|
||||
function useTabsContext() {
|
||||
const context = useContext(TabsContext)
|
||||
if (!context) {
|
||||
throw new Error('Tab components must be used within a Tabs component')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Tabs container
|
||||
export interface TabsProps {
|
||||
tabs: Tab[]
|
||||
defaultTab?: string
|
||||
activeTab?: string
|
||||
onChange?: (id: string) => void
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'pills' | 'underline'
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
defaultTab,
|
||||
activeTab: controlledActiveTab,
|
||||
onChange,
|
||||
children,
|
||||
variant = 'default',
|
||||
fullWidth = false,
|
||||
}: TabsProps) {
|
||||
const [internalActiveTab, setInternalActiveTab] = useState(
|
||||
defaultTab || tabs[0]?.id
|
||||
)
|
||||
|
||||
const activeTab = controlledActiveTab ?? internalActiveTab
|
||||
|
||||
const setActiveTab = (id: string) => {
|
||||
if (!controlledActiveTab) {
|
||||
setInternalActiveTab(id)
|
||||
}
|
||||
onChange?.(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<div>
|
||||
{/* Tab list */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
variant === 'default' && 'border-b border-slate-700',
|
||||
variant === 'pills' && 'gap-2 p-1 bg-slate-800/50 rounded-lg',
|
||||
variant === 'underline' && 'border-b border-slate-700',
|
||||
fullWidth && 'w-full'
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
variant={variant}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab panels */}
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab button
|
||||
interface TabButtonProps {
|
||||
tab: Tab
|
||||
variant: 'default' | 'pills' | 'underline'
|
||||
fullWidth: boolean
|
||||
}
|
||||
|
||||
function TabButton({ tab, variant, fullWidth }: TabButtonProps) {
|
||||
const { activeTab, setActiveTab } = useTabsContext()
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
const baseStyles =
|
||||
'relative flex items-center justify-center gap-2 font-medium transition-all duration-200'
|
||||
|
||||
const variantStyles = {
|
||||
default: clsx(
|
||||
'px-4 py-3 text-sm -mb-px border-b-2',
|
||||
isActive
|
||||
? 'border-accent-500 text-white'
|
||||
: 'border-transparent text-slate-400 hover:text-white hover:border-slate-600'
|
||||
),
|
||||
pills: clsx(
|
||||
'px-4 py-2 text-sm rounded-md',
|
||||
isActive
|
||||
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||
),
|
||||
underline: clsx(
|
||||
'px-4 py-3 text-sm border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-accent-500 text-accent-500'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
fullWidth && 'flex-1',
|
||||
tab.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <span className="w-5 h-5">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-1.5 px-1.5 py-0.5 text-xs font-bold rounded-full',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-slate-700 text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab.badge > 99 ? '99+' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab panel
|
||||
export interface TabPanelProps {
|
||||
id: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabPanel({ id, children, className }: TabPanelProps) {
|
||||
const { activeTab } = useTabsContext()
|
||||
|
||||
if (activeTab !== id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('animate-fade-in', className)} role="tabpanel">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple tabs (all-in-one component)
|
||||
export interface SimpleTabsProps {
|
||||
tabs: Array<{
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
content: ReactNode
|
||||
}>
|
||||
defaultTab?: string
|
||||
variant?: 'default' | 'pills' | 'underline'
|
||||
}
|
||||
|
||||
export function SimpleTabs({
|
||||
tabs,
|
||||
defaultTab,
|
||||
variant = 'default',
|
||||
}: SimpleTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
tabs={tabs.map((t) => ({ id: t.id, label: t.label, icon: t.icon }))}
|
||||
defaultTab={defaultTab}
|
||||
variant={variant}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} id={tab.id}>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/ui/Toast.tsx
Normal file
214
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void
|
||||
removeToast: (id: string) => void
|
||||
success: (title: string, message?: string) => void
|
||||
error: (title: string, message?: string) => void
|
||||
warning: (title: string, message?: string) => void
|
||||
info: (title: string, message?: string) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
|
||||
|
||||
// Hook
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Provider
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
export function ToastProvider({
|
||||
children,
|
||||
position = 'top-right',
|
||||
}: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const newToast: Toast = { ...toast, id }
|
||||
|
||||
setToasts((prev) => [...prev, newToast])
|
||||
|
||||
// Auto remove after duration
|
||||
const duration = toast.duration ?? 5000
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const success = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'success', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const error = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'error', title, message, duration: 8000 })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const warning = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'warning', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const info = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'info', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const positionStyles = {
|
||||
'top-right': 'top-4 right-4',
|
||||
'top-left': 'top-4 left-4',
|
||||
'bottom-right': 'bottom-4 right-4',
|
||||
'bottom-left': 'bottom-4 left-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ toasts, addToast, removeToast, success, error, warning, info }}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Toast container */}
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed z-50 flex flex-col gap-3 pointer-events-none',
|
||||
positionStyles[position]
|
||||
)}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast item
|
||||
interface ToastItemProps {
|
||||
toast: Toast
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const icons: Record<ToastType, typeof CheckCircleIcon> = {
|
||||
success: CheckCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
warning: ExclamationTriangleIcon,
|
||||
info: InformationCircleIcon,
|
||||
}
|
||||
|
||||
const styles: Record<ToastType, { icon: string; border: string }> = {
|
||||
success: {
|
||||
icon: 'text-success-500',
|
||||
border: 'border-l-success-500',
|
||||
},
|
||||
error: {
|
||||
icon: 'text-error-500',
|
||||
border: 'border-l-error-500',
|
||||
},
|
||||
warning: {
|
||||
icon: 'text-warning-500',
|
||||
border: 'border-l-warning-500',
|
||||
},
|
||||
info: {
|
||||
icon: 'text-accent-500',
|
||||
border: 'border-l-accent-500',
|
||||
},
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: ToastItemProps) {
|
||||
const Icon = icons[toast.type]
|
||||
const style = styles[toast.type]
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={true}
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enterFrom="translate-x-full opacity-0"
|
||||
enterTo="translate-x-0 opacity-100"
|
||||
leave="transition ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-auto w-80 overflow-hidden rounded-lg',
|
||||
'bg-card border border-slate-700/50 shadow-xl',
|
||||
'border-l-4',
|
||||
style.border
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className={clsx('h-5 w-5', style.icon)} />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium text-white">{toast.title}</p>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-slate-400">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex rounded-md text-slate-400 hover:text-slate-300 focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-card"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastProvider
|
||||
52
frontend/src/components/ui/index.ts
Normal file
52
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export { default as Button } from './Button'
|
||||
export type { ButtonProps } from './Button'
|
||||
|
||||
export { default as Card, CardHeader, CardContent, CardFooter } from './Card'
|
||||
export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } from './Card'
|
||||
|
||||
export { default as Modal, ModalFooter, ConfirmModal } from './Modal'
|
||||
export type { ModalProps, ModalFooterProps, ConfirmModalProps } from './Modal'
|
||||
|
||||
export { default as Input, Textarea } from './Input'
|
||||
export type { InputProps, TextareaProps } from './Input'
|
||||
|
||||
export { default as Select, NativeSelect } from './Select'
|
||||
export type { SelectProps, SelectOption, NativeSelectProps } from './Select'
|
||||
|
||||
export { default as Checkbox, Switch, Radio } from './Checkbox'
|
||||
export type { CheckboxProps, SwitchProps, RadioProps } from './Checkbox'
|
||||
|
||||
export { default as Badge, StatusBadge, PriorityBadge, CounterBadge } from './Badge'
|
||||
export type {
|
||||
BadgeProps,
|
||||
BadgeVariant,
|
||||
BadgeSize,
|
||||
StatusBadgeProps,
|
||||
StatusType,
|
||||
PriorityBadgeProps,
|
||||
PriorityType,
|
||||
CounterBadgeProps,
|
||||
} from './Badge'
|
||||
|
||||
export { ToastProvider, useToast } from './Toast'
|
||||
export type { Toast, ToastType } from './Toast'
|
||||
|
||||
export {
|
||||
default as Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonTableRow,
|
||||
SkeletonListItem,
|
||||
SkeletonStats,
|
||||
SkeletonChart,
|
||||
SkeletonMap,
|
||||
SkeletonForm,
|
||||
} from './Skeleton'
|
||||
|
||||
export { default as Dropdown, DropdownButton, ActionMenu } from './Dropdown'
|
||||
export type { DropdownProps, DropdownItem, DropdownButtonProps, ActionMenuProps } from './Dropdown'
|
||||
|
||||
export { default as Tabs, TabPanel, SimpleTabs } from './Tabs'
|
||||
export type { Tab, TabsProps, TabPanelProps, SimpleTabsProps } from './Tabs'
|
||||
|
||||
export { default as Table } from './Table'
|
||||
export type { TableProps, Column } from './Table'
|
||||
Reference in New Issue
Block a user