Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -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 => {

View File

@@ -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;

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

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

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

View File

@@ -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';

View File

@@ -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>

View File

@@ -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';