Improve the frontend and repository layer
This commit is contained in:
@@ -257,6 +257,9 @@ export interface AddModalProps {
|
||||
// Validation
|
||||
validationErrors?: Record<string, string>;
|
||||
onValidationError?: (errors: Record<string, string>) => void;
|
||||
|
||||
// Field change callback for dynamic form behavior
|
||||
onFieldChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +288,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
initialData = EMPTY_INITIAL_DATA,
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
onFieldChange,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
@@ -356,6 +360,9 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent component of field change
|
||||
onFieldChange?.(fieldName, value);
|
||||
};
|
||||
|
||||
const findFieldByName = (fieldName: string): AddModalField | undefined => {
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
import React, { forwardRef, HTMLAttributes, useMemo } from 'react';
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
/**
|
||||
* Visual style variant
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
shape?: 'rounded' | 'pill' | 'square';
|
||||
dot?: boolean;
|
||||
count?: number;
|
||||
showZero?: boolean;
|
||||
max?: number;
|
||||
offset?: [number, number];
|
||||
status?: 'default' | 'error' | 'success' | 'warning' | 'processing';
|
||||
text?: string;
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Optional icon to display before the text
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Whether the badge is closable
|
||||
* @default false
|
||||
*/
|
||||
closable?: boolean;
|
||||
onClose?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
|
||||
/**
|
||||
* Callback when close button is clicked
|
||||
*/
|
||||
onClose?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
/**
|
||||
* Badge - Simple label/tag component for displaying status, categories, or labels
|
||||
*
|
||||
* Features:
|
||||
* - Theme-aware with CSS custom properties
|
||||
* - Multiple semantic variants (success, warning, error, info)
|
||||
* - Three size options (sm, md, lg)
|
||||
* - Optional icon support
|
||||
* - Optional close button
|
||||
* - Accessible with proper ARIA labels
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Badge variant="success">Active</Badge>
|
||||
* <Badge variant="warning" icon={<AlertCircle />}>Warning</Badge>
|
||||
* <Badge variant="error" closable onClose={handleClose}>Error</Badge>
|
||||
* ```
|
||||
*/
|
||||
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
shape = 'rounded',
|
||||
dot = false,
|
||||
count,
|
||||
showZero = false,
|
||||
max = 99,
|
||||
offset,
|
||||
status,
|
||||
text,
|
||||
color,
|
||||
icon,
|
||||
closable = false,
|
||||
onClose,
|
||||
@@ -37,201 +59,138 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const hasChildren = children !== undefined;
|
||||
const isStandalone = !hasChildren;
|
||||
|
||||
// Calculate display count
|
||||
const displayCount = useMemo(() => {
|
||||
if (count === undefined || dot) return undefined;
|
||||
if (count === 0 && !showZero) return undefined;
|
||||
if (count > max) return `${max}+`;
|
||||
return count.toString();
|
||||
}, [count, dot, showZero, max]);
|
||||
|
||||
// Base classes for all badges
|
||||
const baseClasses = [
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'inline-flex items-center justify-center',
|
||||
'font-medium whitespace-nowrap',
|
||||
'border',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'whitespace-nowrap',
|
||||
];
|
||||
|
||||
// Variant styling using CSS custom properties
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: {},
|
||||
primary: {
|
||||
backgroundColor: 'var(--color-primary)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-primary)',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'var(--color-secondary)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-secondary)',
|
||||
},
|
||||
success: {
|
||||
backgroundColor: 'var(--color-success)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-success)',
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: 'var(--color-warning)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-warning)',
|
||||
},
|
||||
error: {
|
||||
backgroundColor: 'var(--color-error)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-error)',
|
||||
},
|
||||
info: {
|
||||
backgroundColor: 'var(--color-info)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-info)',
|
||||
},
|
||||
outline: {},
|
||||
};
|
||||
|
||||
// Variant-specific classes using CSS custom properties
|
||||
const variantClasses = {
|
||||
default: [
|
||||
'bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-primary)]',
|
||||
'bg-[var(--bg-tertiary)]',
|
||||
'text-[var(--text-primary)]',
|
||||
'border-[var(--border-primary)]',
|
||||
],
|
||||
primary: [
|
||||
'bg-[var(--color-primary)]',
|
||||
'text-white',
|
||||
'border-[var(--color-primary)]',
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)]',
|
||||
'text-white',
|
||||
'border-[var(--color-secondary)]',
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)]',
|
||||
'text-white',
|
||||
'border-[var(--color-success)]',
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)]',
|
||||
'text-white',
|
||||
'border-[var(--color-warning)]',
|
||||
],
|
||||
error: [
|
||||
'bg-[var(--color-error)]',
|
||||
'text-white',
|
||||
'border-[var(--color-error)]',
|
||||
],
|
||||
info: [
|
||||
'bg-[var(--color-info)]',
|
||||
'text-white',
|
||||
'border-[var(--color-info)]',
|
||||
],
|
||||
primary: [],
|
||||
secondary: [],
|
||||
success: [],
|
||||
warning: [],
|
||||
error: [],
|
||||
info: [],
|
||||
outline: [
|
||||
'bg-transparent border border-current',
|
||||
'bg-transparent',
|
||||
'text-[var(--text-primary)]',
|
||||
'border-[var(--border-secondary)]',
|
||||
],
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeClasses = {
|
||||
xs: isStandalone ? 'px-1.5 py-0.5 text-xs min-h-4' : 'w-4 h-4 text-xs',
|
||||
sm: isStandalone ? 'px-3 py-1.5 text-sm min-h-6 font-medium' : 'w-5 h-5 text-xs',
|
||||
md: isStandalone ? 'px-3 py-1.5 text-sm min-h-7 font-semibold' : 'w-6 h-6 text-sm',
|
||||
lg: isStandalone ? 'px-4 py-2 text-base min-h-8 font-semibold' : 'w-7 h-7 text-sm',
|
||||
sm: [
|
||||
'px-2 py-0.5',
|
||||
'text-xs',
|
||||
'gap-1',
|
||||
'rounded-md',
|
||||
'min-h-5',
|
||||
],
|
||||
md: [
|
||||
'px-3 py-1',
|
||||
'text-sm',
|
||||
'gap-1.5',
|
||||
'rounded-lg',
|
||||
'min-h-6',
|
||||
],
|
||||
lg: [
|
||||
'px-4 py-1.5',
|
||||
'text-base',
|
||||
'gap-2',
|
||||
'rounded-lg',
|
||||
'min-h-8',
|
||||
],
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
rounded: 'rounded-lg',
|
||||
pill: 'rounded-full',
|
||||
square: 'rounded-none',
|
||||
// Icon size based on badge size
|
||||
const iconSizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
const statusClasses = {
|
||||
default: 'bg-text-tertiary',
|
||||
error: 'bg-color-error',
|
||||
success: 'bg-color-success animate-pulse',
|
||||
warning: 'bg-color-warning',
|
||||
processing: 'bg-color-info animate-pulse',
|
||||
};
|
||||
|
||||
// Dot badge (status indicator)
|
||||
if (dot || status) {
|
||||
const dotClasses = clsx(
|
||||
'w-2 h-2 rounded-full',
|
||||
status ? statusClasses[status] : 'bg-color-primary'
|
||||
);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<span className="relative inline-flex" ref={ref}>
|
||||
{children}
|
||||
<span
|
||||
className={clsx(
|
||||
dotClasses,
|
||||
'absolute -top-0.5 -right-0.5 ring-2 ring-bg-primary',
|
||||
className
|
||||
)}
|
||||
style={offset ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(dotClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Count badge
|
||||
if (count !== undefined && hasChildren) {
|
||||
if (displayCount === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex" ref={ref}>
|
||||
{children}
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute -top-2 -right-2 flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1 text-xs font-medium',
|
||||
'bg-color-error text-text-inverse rounded-full',
|
||||
'ring-2 ring-bg-primary',
|
||||
className
|
||||
)}
|
||||
style={offset ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined}
|
||||
{...props}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone badge
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
'border', // Always include border
|
||||
{
|
||||
'gap-2': icon || closable,
|
||||
'pr-2': closable,
|
||||
'pr-1.5': closable && size === 'sm',
|
||||
'pr-2': closable && size === 'md',
|
||||
'pr-2.5': closable && size === 'lg',
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
// Merge custom style with variant style
|
||||
const customStyle = color
|
||||
? {
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: getContrastColor(color),
|
||||
}
|
||||
: variantStyles[variant] || {};
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={classes}
|
||||
style={customStyle}
|
||||
role="status"
|
||||
aria-label={typeof children === 'string' ? children : undefined}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex-shrink-0 flex items-center">{icon}</span>
|
||||
<span className={clsx('flex-shrink-0', iconSizeClasses[size])} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">{text || displayCount || children}</span>
|
||||
|
||||
<span className="flex-1">{children}</span>
|
||||
|
||||
{closable && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 ml-1 hover:bg-black/10 rounded-full p-0.5 transition-colors duration-150"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar"
|
||||
className={clsx(
|
||||
'flex-shrink-0 ml-1',
|
||||
'rounded-full',
|
||||
'hover:bg-black/10 dark:hover:bg-white/10',
|
||||
'transition-colors duration-150',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-1',
|
||||
'focus:ring-[var(--border-focus)]',
|
||||
{
|
||||
'p-0.5': size === 'sm',
|
||||
'p-1': size === 'md' || size === 'lg',
|
||||
}
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
className={iconSizeClasses[size]}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -247,23 +206,6 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to determine contrast color
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const color = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(color.substr(0, 2), 16);
|
||||
const g = parseInt(color.substr(2, 2), 16);
|
||||
const b = parseInt(color.substr(4, 2), 16);
|
||||
|
||||
// Calculate relative luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
// Return black for light colors, white for dark colors
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export default Badge;
|
||||
export default Badge;
|
||||
|
||||
194
frontend/src/components/ui/Badge/CountBadge.tsx
Normal file
194
frontend/src/components/ui/Badge/CountBadge.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface CountBadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
|
||||
/**
|
||||
* The count to display
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* Maximum count to display before showing "99+"
|
||||
* @default 99
|
||||
*/
|
||||
max?: number;
|
||||
|
||||
/**
|
||||
* Whether to show zero counts
|
||||
* @default false
|
||||
*/
|
||||
showZero?: boolean;
|
||||
|
||||
/**
|
||||
* Visual style variant
|
||||
* @default 'error'
|
||||
*/
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Position offset when used as overlay [x, y]
|
||||
* @example [4, -4] moves badge 4px right and 4px up
|
||||
*/
|
||||
offset?: [number, number];
|
||||
|
||||
/**
|
||||
* Whether this badge is positioned as an overlay
|
||||
* @default false
|
||||
*/
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CountBadge - Displays numerical counts, typically for notifications
|
||||
*
|
||||
* Features:
|
||||
* - Automatic max count display (99+)
|
||||
* - Optional zero count hiding
|
||||
* - Overlay mode for positioning over other elements
|
||||
* - Multiple semantic variants
|
||||
* - Responsive sizing
|
||||
* - Accessible with proper ARIA labels
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Standalone count badge
|
||||
* <CountBadge count={5} />
|
||||
*
|
||||
* // As overlay on an icon
|
||||
* <div className="relative">
|
||||
* <Bell />
|
||||
* <CountBadge count={12} overlay />
|
||||
* </div>
|
||||
*
|
||||
* // With custom positioning
|
||||
* <CountBadge count={99} overlay offset={[2, -2]} />
|
||||
* ```
|
||||
*/
|
||||
export const CountBadge = forwardRef<HTMLSpanElement, CountBadgeProps>(({
|
||||
count,
|
||||
max = 99,
|
||||
showZero = false,
|
||||
variant = 'error',
|
||||
size = 'md',
|
||||
offset,
|
||||
overlay = false,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}, ref) => {
|
||||
// Don't render if count is 0 and showZero is false
|
||||
if (count === 0 && !showZero) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format the display count
|
||||
const displayCount = count > max ? `${max}+` : count.toString();
|
||||
|
||||
// Base classes for all count badges
|
||||
const baseClasses = [
|
||||
'inline-flex items-center justify-center',
|
||||
'font-semibold tabular-nums',
|
||||
'whitespace-nowrap',
|
||||
'rounded-full',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
];
|
||||
|
||||
// Overlay-specific classes
|
||||
const overlayClasses = overlay ? [
|
||||
'absolute',
|
||||
'ring-2 ring-[var(--bg-primary)]',
|
||||
] : [];
|
||||
|
||||
// Variant-specific classes using CSS custom properties
|
||||
const variantClasses = {
|
||||
primary: [
|
||||
'bg-[var(--color-primary)]',
|
||||
'text-white',
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)]',
|
||||
'text-white',
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)]',
|
||||
'text-white',
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)]',
|
||||
'text-white',
|
||||
],
|
||||
error: [
|
||||
'bg-[var(--color-error)]',
|
||||
'text-white',
|
||||
],
|
||||
info: [
|
||||
'bg-[var(--color-info)]',
|
||||
'text-white',
|
||||
],
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeClasses = {
|
||||
sm: [
|
||||
'min-w-4 h-4',
|
||||
'text-xs',
|
||||
'px-1',
|
||||
],
|
||||
md: [
|
||||
'min-w-5 h-5',
|
||||
'text-xs',
|
||||
'px-1.5',
|
||||
],
|
||||
lg: [
|
||||
'min-w-6 h-6',
|
||||
'text-sm',
|
||||
'px-2',
|
||||
],
|
||||
};
|
||||
|
||||
// Overlay positioning classes
|
||||
const overlayPositionClasses = overlay ? [
|
||||
'-top-1',
|
||||
'-right-1',
|
||||
] : [];
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
overlayClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
overlayPositionClasses,
|
||||
className
|
||||
);
|
||||
|
||||
// Calculate offset style if provided
|
||||
const offsetStyle = offset && overlay ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={classes}
|
||||
style={{
|
||||
...style,
|
||||
...offsetStyle,
|
||||
}}
|
||||
role="status"
|
||||
aria-label={`${count} notification${count !== 1 ? 's' : ''}`}
|
||||
{...props}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
CountBadge.displayName = 'CountBadge';
|
||||
|
||||
export default CountBadge;
|
||||
169
frontend/src/components/ui/Badge/SeverityBadge.tsx
Normal file
169
frontend/src/components/ui/Badge/SeverityBadge.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { AlertTriangle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
export type SeverityLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface SeverityBadgeProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
/**
|
||||
* Severity level determining color and icon
|
||||
* @default 'medium'
|
||||
*/
|
||||
severity: SeverityLevel;
|
||||
|
||||
/**
|
||||
* Count to display
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* Label text to display
|
||||
* @default Auto-generated from severity ('ALTO', 'MEDIO', 'BAJO')
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Size variant
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Whether to show the icon
|
||||
* @default true
|
||||
*/
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SeverityBadge - Displays alert severity with icon, count, and label
|
||||
*
|
||||
* Matches the reference design showing badges like "9 ALTO" and "19 MEDIO"
|
||||
*
|
||||
* Features:
|
||||
* - Severity-based color coding (high=red, medium=yellow, low=blue)
|
||||
* - Icon + count + label layout
|
||||
* - Consistent sizing and spacing
|
||||
* - Accessible with proper ARIA labels
|
||||
* - Theme-aware with CSS custom properties
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SeverityBadge severity="high" count={9} />
|
||||
* <SeverityBadge severity="medium" count={19} />
|
||||
* <SeverityBadge severity="low" count={3} label="BAJO" />
|
||||
* ```
|
||||
*/
|
||||
export const SeverityBadge = forwardRef<HTMLDivElement, SeverityBadgeProps>(({
|
||||
severity,
|
||||
count,
|
||||
label,
|
||||
size = 'md',
|
||||
showIcon = true,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
// Default labels based on severity
|
||||
const defaultLabels: Record<SeverityLevel, string> = {
|
||||
high: 'ALTO',
|
||||
medium: 'MEDIO',
|
||||
low: 'BAJO',
|
||||
};
|
||||
|
||||
const displayLabel = label || defaultLabels[severity];
|
||||
|
||||
// Icons for each severity level
|
||||
const severityIcons: Record<SeverityLevel, React.ElementType> = {
|
||||
high: AlertTriangle,
|
||||
medium: AlertCircle,
|
||||
low: Info,
|
||||
};
|
||||
|
||||
const Icon = severityIcons[severity];
|
||||
|
||||
// Base classes
|
||||
const baseClasses = [
|
||||
'inline-flex items-center',
|
||||
'rounded-full',
|
||||
'font-semibold',
|
||||
'border-2',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
];
|
||||
|
||||
// Severity-specific classes using CSS custom properties
|
||||
const severityClasses = {
|
||||
high: [
|
||||
'bg-[var(--color-error-100)]',
|
||||
'text-[var(--color-error-700)]',
|
||||
'border-[var(--color-error-300)]',
|
||||
],
|
||||
medium: [
|
||||
'bg-[var(--color-warning-100)]',
|
||||
'text-[var(--color-warning-700)]',
|
||||
'border-[var(--color-warning-300)]',
|
||||
],
|
||||
low: [
|
||||
'bg-[var(--color-info-100)]',
|
||||
'text-[var(--color-info-700)]',
|
||||
'border-[var(--color-info-300)]',
|
||||
],
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
container: 'gap-1.5 px-2.5 py-1',
|
||||
text: 'text-xs',
|
||||
icon: 'w-3.5 h-3.5',
|
||||
},
|
||||
md: {
|
||||
container: 'gap-2 px-3 py-1.5',
|
||||
text: 'text-sm',
|
||||
icon: 'w-4 h-4',
|
||||
},
|
||||
lg: {
|
||||
container: 'gap-2.5 px-4 py-2',
|
||||
text: 'text-base',
|
||||
icon: 'w-5 h-5',
|
||||
},
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
severityClasses[severity],
|
||||
sizeClasses[size].container,
|
||||
className
|
||||
);
|
||||
|
||||
// Accessibility label
|
||||
const ariaLabel = `${count} ${displayLabel.toLowerCase()} severity alert${count !== 1 ? 's' : ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
className={clsx('flex-shrink-0', sizeClasses[size].icon)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className={clsx('font-bold tabular-nums', sizeClasses[size].text)}>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
<span className={clsx('uppercase tracking-wide', sizeClasses[size].text)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SeverityBadge.displayName = 'SeverityBadge';
|
||||
|
||||
export default SeverityBadge;
|
||||
179
frontend/src/components/ui/Badge/StatusDot.tsx
Normal file
179
frontend/src/components/ui/Badge/StatusDot.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface StatusDotProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
/**
|
||||
* Status variant determining color and animation
|
||||
* @default 'default'
|
||||
*/
|
||||
status?: 'default' | 'success' | 'error' | 'warning' | 'info' | 'processing';
|
||||
|
||||
/**
|
||||
* Size of the status dot
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Whether to show a pulse animation
|
||||
* @default false (true for 'processing' and 'success' status)
|
||||
*/
|
||||
pulse?: boolean;
|
||||
|
||||
/**
|
||||
* Position offset when used as overlay [x, y]
|
||||
* @example [4, -4] moves dot 4px right and 4px up
|
||||
*/
|
||||
offset?: [number, number];
|
||||
|
||||
/**
|
||||
* Whether this dot is positioned as an overlay
|
||||
* @default false
|
||||
*/
|
||||
overlay?: boolean;
|
||||
|
||||
/**
|
||||
* Optional text label to display next to the dot
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot - Displays status indicators as colored dots
|
||||
*
|
||||
* Features:
|
||||
* - Multiple status variants (online/offline/busy/processing)
|
||||
* - Optional pulse animation
|
||||
* - Standalone or overlay mode
|
||||
* - Optional text label
|
||||
* - Responsive sizing
|
||||
* - Accessible with proper ARIA labels
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Standalone status dot
|
||||
* <StatusDot status="success" />
|
||||
*
|
||||
* // With label
|
||||
* <StatusDot status="success" label="Online" />
|
||||
*
|
||||
* // As overlay on avatar
|
||||
* <div className="relative">
|
||||
* <Avatar />
|
||||
* <StatusDot status="success" overlay />
|
||||
* </div>
|
||||
*
|
||||
* // With pulse animation
|
||||
* <StatusDot status="processing" pulse />
|
||||
* ```
|
||||
*/
|
||||
export const StatusDot = forwardRef<HTMLSpanElement, StatusDotProps>(({
|
||||
status = 'default',
|
||||
size = 'md',
|
||||
pulse = status === 'processing' || status === 'success',
|
||||
offset,
|
||||
overlay = false,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}, ref) => {
|
||||
// Base container classes
|
||||
const containerClasses = label ? [
|
||||
'inline-flex items-center gap-2',
|
||||
] : [];
|
||||
|
||||
// Base dot classes
|
||||
const baseDotClasses = [
|
||||
'rounded-full',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
];
|
||||
|
||||
// Overlay-specific classes
|
||||
const overlayClasses = overlay ? [
|
||||
'absolute',
|
||||
'ring-2 ring-[var(--bg-primary)]',
|
||||
'bottom-0',
|
||||
'right-0',
|
||||
] : [];
|
||||
|
||||
// Status-specific classes using CSS custom properties
|
||||
const statusClasses = {
|
||||
default: 'bg-[var(--text-tertiary)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
error: 'bg-[var(--color-error)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
info: 'bg-[var(--color-info)]',
|
||||
processing: 'bg-[var(--color-info)]',
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeClasses = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-2.5 h-2.5',
|
||||
lg: 'w-3 h-3',
|
||||
};
|
||||
|
||||
// Pulse animation classes
|
||||
const pulseClasses = pulse ? 'animate-pulse' : '';
|
||||
|
||||
const dotClasses = clsx(
|
||||
baseDotClasses,
|
||||
overlayClasses,
|
||||
statusClasses[status],
|
||||
sizeClasses[size],
|
||||
pulseClasses,
|
||||
);
|
||||
|
||||
// Calculate offset style if provided
|
||||
const offsetStyle = offset && overlay ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined;
|
||||
|
||||
// Status labels for accessibility
|
||||
const statusLabels = {
|
||||
default: 'Default',
|
||||
success: 'Online',
|
||||
error: 'Offline',
|
||||
warning: 'Away',
|
||||
info: 'Busy',
|
||||
processing: 'Processing',
|
||||
};
|
||||
|
||||
const ariaLabel = label || statusLabels[status];
|
||||
|
||||
// If there's a label, render as a container with dot + text
|
||||
if (label && !overlay) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(containerClasses, className)}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
{...props}
|
||||
>
|
||||
<span className={dotClasses} aria-hidden="true" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render just the dot
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(dotClasses, className)}
|
||||
style={{
|
||||
...style,
|
||||
...offsetStyle,
|
||||
}}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
StatusDot.displayName = 'StatusDot';
|
||||
|
||||
export default StatusDot;
|
||||
@@ -1,3 +1,24 @@
|
||||
export { default } from './Badge';
|
||||
export { default as Badge } from './Badge';
|
||||
export type { BadgeProps } from './Badge';
|
||||
/**
|
||||
* Badge Components
|
||||
*
|
||||
* A collection of badge components for different use cases:
|
||||
* - Badge: Simple label/tag badges for status, categories, or labels
|
||||
* - CountBadge: Notification count badges with overlay support
|
||||
* - StatusDot: Status indicator dots (online/offline/busy)
|
||||
* - SeverityBadge: Alert severity badges with icon + count + label
|
||||
*/
|
||||
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps } from './Badge';
|
||||
|
||||
export { CountBadge } from './CountBadge';
|
||||
export type { CountBadgeProps } from './CountBadge';
|
||||
|
||||
export { StatusDot } from './StatusDot';
|
||||
export type { StatusDotProps } from './StatusDot';
|
||||
|
||||
export { SeverityBadge } from './SeverityBadge';
|
||||
export type { SeverityBadgeProps, SeverityLevel } from './SeverityBadge';
|
||||
|
||||
// Default export for convenience
|
||||
export { Badge as default } from './Badge';
|
||||
@@ -120,7 +120,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
return (
|
||||
<Card
|
||||
className={`
|
||||
p-4 sm:p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||
p-5 sm:p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.01]' : ''}
|
||||
${statusIndicator.isCritical
|
||||
? 'ring-2 ring-red-200 shadow-md border-l-6 sm:border-l-8'
|
||||
@@ -140,39 +140,47 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<div className="space-y-4">
|
||||
{/* Header with status indicator */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`flex-shrink-0 p-2 sm:p-3 rounded-xl shadow-sm ${
|
||||
className={`flex-shrink-0 p-2.5 sm:p-3 rounded-lg shadow-sm ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-white' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: `${statusIndicator.color}20` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-4 h-4 sm:w-5 sm:h-5"
|
||||
className="w-5 h-5 sm:w-6 sm:h-6"
|
||||
style={{ color: statusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 ${overflowClasses.truncate}`}
|
||||
className={`font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-2 ${overflowClasses.truncate}`}
|
||||
title={title}
|
||||
>
|
||||
{truncationEngine.title(title)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{subtitle && (
|
||||
<div
|
||||
className={`inline-flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs font-semibold transition-all ${
|
||||
className={`text-sm text-[var(--text-secondary)] mb-2 ${overflowClasses.truncate}`}
|
||||
title={subtitle}
|
||||
>
|
||||
{truncationEngine.subtitle(subtitle)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
statusIndicator.isCritical
|
||||
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
} max-w-[120px] sm:max-w-[150px]`}
|
||||
} max-w-[140px] sm:max-w-[160px]`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
@@ -184,39 +192,31 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}}
|
||||
>
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="mr-1 text-sm flex-shrink-0">🚨</span>
|
||||
<span className="mr-1.5 text-sm flex-shrink-0">🚨</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="mr-1 flex-shrink-0">⚠️</span>
|
||||
<span className="mr-1.5 flex-shrink-0">⚠️</span>
|
||||
)}
|
||||
<span
|
||||
className={`${overflowClasses.truncate} flex-1`}
|
||||
title={statusIndicator.text}
|
||||
>
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 12 : 15)}
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 14 : 18)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
className={`text-sm text-[var(--text-secondary)] ${overflowClasses.truncate}`}
|
||||
title={subtitle}
|
||||
>
|
||||
{truncationEngine.subtitle(subtitle)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4 min-w-0 max-w-[120px] sm:max-w-[150px]">
|
||||
<div className="text-right flex-shrink-0 min-w-0 max-w-[130px] sm:max-w-[160px]">
|
||||
<div
|
||||
className={`text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none ${overflowClasses.truncate}`}
|
||||
className={`text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none mb-1 ${overflowClasses.truncate}`}
|
||||
title={primaryValue?.toString()}
|
||||
>
|
||||
{safeText(primaryValue?.toString(), '0', isMobile ? 10 : 15)}
|
||||
{safeText(primaryValue?.toString(), '0', isMobile ? 12 : 18)}
|
||||
</div>
|
||||
{primaryValueLabel && (
|
||||
<div
|
||||
className={`text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 ${overflowClasses.truncate}`}
|
||||
className={`text-xs text-[var(--text-tertiary)] uppercase tracking-wide ${overflowClasses.truncate}`}
|
||||
title={primaryValueLabel}
|
||||
>
|
||||
{truncationEngine.primaryValueLabel(primaryValueLabel)}
|
||||
@@ -284,9 +284,9 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
|
||||
{/* Simplified Action System - Mobile optimized */}
|
||||
{actions.length > 0 && (
|
||||
<div className="pt-3 sm:pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="pt-4 border-t border-[var(--border-primary)]">
|
||||
{/* All actions in a clean horizontal layout */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
|
||||
{/* Primary action as a subtle text button */}
|
||||
{primaryActions.length > 0 && (
|
||||
@@ -299,8 +299,8 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}}
|
||||
disabled={primaryActions[0].disabled}
|
||||
className={`
|
||||
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0 max-w-[120px] sm:max-w-[150px]
|
||||
flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0 max-w-[140px] sm:max-w-[160px]
|
||||
${primaryActions[0].disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: primaryActions[0].destructive
|
||||
@@ -310,7 +310,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
`}
|
||||
title={primaryActions[0].label}
|
||||
>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" })}
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4 flex-shrink-0" })}
|
||||
<span className={`${overflowClasses.truncate} flex-1`}>
|
||||
{truncationEngine.actionLabel(primaryActions[0].label)}
|
||||
</span>
|
||||
@@ -318,7 +318,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
)}
|
||||
|
||||
{/* Action icons for secondary actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{secondaryActions.map((action, index) => (
|
||||
<button
|
||||
key={`action-${index}`}
|
||||
@@ -331,16 +331,16 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
disabled={action.disabled}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 hover:shadow-sm
|
||||
${action.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
|
||||
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -357,7 +357,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
disabled={action.disabled}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 hover:shadow-sm
|
||||
${action.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
@@ -366,7 +366,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}
|
||||
`}
|
||||
>
|
||||
{action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
|
||||
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export { default as Textarea } from './Textarea/Textarea';
|
||||
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
|
||||
export { default as Table } from './Table';
|
||||
export { default as Badge } from './Badge';
|
||||
export { Badge, CountBadge, StatusDot, SeverityBadge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Tooltip } from './Tooltip';
|
||||
export { default as Select } from './Select';
|
||||
@@ -35,7 +35,7 @@ export type { TextareaProps } from './Textarea';
|
||||
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
|
||||
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
|
||||
export type { TableProps, TableColumn, TableRow } from './Table';
|
||||
export type { BadgeProps } from './Badge';
|
||||
export type { BadgeProps, CountBadgeProps, StatusDotProps, SeverityBadgeProps, SeverityLevel } from './Badge';
|
||||
export type { AvatarProps } from './Avatar';
|
||||
export type { TooltipProps } from './Tooltip';
|
||||
export type { SelectProps, SelectOption } from './Select';
|
||||
|
||||
Reference in New Issue
Block a user