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:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View 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>
)
}

View 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

View 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

View 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'

View 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>
}
/>
)
}

View 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'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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'