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

@@ -1,7 +1,7 @@
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { Badge } from '../../ui/Badge';
import { SeverityBadge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useNotifications } from '../../../hooks/useNotifications';
import { useAlertFilters } from '../../../hooks/useAlertFilters';
@@ -18,6 +18,8 @@ import {
AlertTriangle,
AlertCircle,
Clock,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import AlertFilters from './AlertFilters';
import AlertGroupHeader from './AlertGroupHeader';
@@ -61,6 +63,10 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
const [showBulkActions, setShowBulkActions] = useState(false);
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
// Pagination state
const ALERTS_PER_PAGE = 3;
const [currentPage, setCurrentPage] = useState(1);
const {
notifications,
isConnected,
@@ -121,6 +127,32 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
);
}, [groupedAlerts, isGroupCollapsed]);
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [filters, groupingMode]);
// Pagination calculations
const totalAlerts = flatAlerts.length;
const totalPages = Math.ceil(totalAlerts / ALERTS_PER_PAGE);
const startIndex = (currentPage - 1) * ALERTS_PER_PAGE;
const endIndex = startIndex + ALERTS_PER_PAGE;
// Paginated alerts - slice the flat alerts for current page
const paginatedAlerts = useMemo(() => {
const alertsToShow = flatAlerts.slice(startIndex, endIndex);
const alertIds = new Set(alertsToShow.map(a => a.id));
// Filter groups to only show alerts on current page
return groupedAlerts
.map(group => ({
...group,
alerts: group.alerts.filter(alert => alertIds.has(alert.id)),
count: group.alerts.filter(alert => alertIds.has(alert.id)).length,
}))
.filter(group => group.alerts.length > 0);
}, [groupedAlerts, flatAlerts, startIndex, endIndex]);
const { focusedIndex } = useKeyboardNavigation(
flatAlerts.length,
{
@@ -296,22 +328,18 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
{/* Alert count badges */}
<div className="flex items-center gap-2">
{urgentCount > 0 && (
<Badge
variant="error"
<SeverityBadge
severity="high"
count={urgentCount}
size="sm"
icon={<AlertTriangle className="w-4 h-4" />}
>
{urgentCount} Alto
</Badge>
/>
)}
{highCount > 0 && (
<Badge
variant="warning"
<SeverityBadge
severity="medium"
count={highCount}
size="sm"
icon={<AlertCircle className="w-4 h-4" />}
>
{highCount} Medio
</Badge>
/>
)}
</div>
@@ -402,7 +430,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
</div>
) : (
<div className="space-y-3 p-4">
{groupedAlerts.map((group) => (
{paginatedAlerts.map((group) => (
<div key={group.id}>
{(group.count > 1 || groupingMode !== 'none') && (
<div className="mb-3">
@@ -448,24 +476,58 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
backgroundColor: 'var(--bg-secondary)/50',
}}
>
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">
Mostrando <span className="font-bold text-[var(--text-primary)]">{filteredNotifications.length}</span> de <span className="font-bold text-[var(--text-primary)]">{notifications.length}</span> alertas
</span>
<div className="flex items-center gap-4">
{stats.unread > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
</span>
)}
{stats.snoozed > 0 && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
</span>
)}
<div className="flex flex-col gap-3">
{/* Stats row */}
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">
Mostrando <span className="font-bold text-[var(--text-primary)]">{startIndex + 1}-{Math.min(endIndex, totalAlerts)}</span> de <span className="font-bold text-[var(--text-primary)]">{totalAlerts}</span> alertas
</span>
<div className="flex items-center gap-4">
{stats.unread > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
</span>
)}
{stats.snoozed > 0 && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
</span>
)}
</div>
</div>
{/* Pagination controls */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 px-3"
aria-label="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium px-3" style={{ color: 'var(--text-primary)' }}>
Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span>
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-8 px-3"
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}

View File

@@ -31,6 +31,15 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
const [selectedProvider, setSelectedProvider] = useState<POSSystem | ''>('');
const { addToast } = useToast();
// Initialize selectedProvider in edit mode
React.useEffect(() => {
if (mode === 'edit' && existingConfig) {
setSelectedProvider(existingConfig.pos_system as POSSystem);
} else {
setSelectedProvider('');
}
}, [mode, existingConfig]);
// Supported POS providers configuration
const supportedProviders: POSProviderConfig[] = [
{
@@ -160,7 +169,7 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
const credentialFields: AddModalField[] = provider.required_fields.map(field => ({
label: field.label,
name: `credential_${field.field}`,
type: field.type === 'select' ? 'select' : (field.type === 'password' ? 'text' : field.type),
type: field.type === 'select' ? 'select' : 'text', // Map password/url to text
required: field.required,
placeholder: field.placeholder || `Ingresa ${field.label}`,
helpText: field.help_text,
@@ -245,20 +254,33 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
return;
}
// Extract credentials
// Extract credentials and separate top-level fields
const credentials: Record<string, any> = {};
let environment: string | undefined;
let location_id: string | undefined;
provider.required_fields.forEach(field => {
const credKey = `credential_${field.field}`;
if (formData[credKey]) {
credentials[field.field] = formData[credKey];
const value = formData[credKey];
// Extract environment and location_id to top level, but keep in credentials too
if (field.field === 'environment') {
environment = value;
} else if (field.field === 'location_id') {
location_id = value;
}
credentials[field.field] = value;
}
});
// Build request payload
const payload = {
// Build request payload with correct field names
const payload: any = {
tenant_id: tenantId,
provider: formData.provider,
config_name: formData.config_name,
pos_system: formData.provider as POSSystem, // FIXED: was 'provider'
provider_name: formData.config_name as string, // FIXED: was 'config_name'
environment: (environment || 'sandbox') as POSEnvironment, // FIXED: extract from credentials
credentials,
sync_settings: {
auto_sync_enabled: formData.auto_sync_enabled === 'true' || formData.auto_sync_enabled === true,
@@ -266,7 +288,8 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
sync_sales: formData.sync_sales === 'true' || formData.sync_sales === true,
sync_inventory: formData.sync_inventory === 'true' || formData.sync_inventory === true,
sync_customers: false
}
},
...(location_id && { location_id }) // FIXED: add location_id if present
};
// Create or update configuration
@@ -292,6 +315,13 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
}
};
// Handle field changes to update selectedProvider dynamically
const handleFieldChange = (fieldName: string, value: any) => {
if (fieldName === 'provider') {
setSelectedProvider(value as POSSystem | '');
}
};
return (
<AddModal
isOpen={isOpen}
@@ -318,6 +348,7 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
addToast(firstError, { type: 'error' });
}
}}
onFieldChange={handleFieldChange}
/>
);
};

View File

@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Leaf,
Droplets,
TreeDeciduous,
TrendingDown,
Award,
FileText,
ChevronRight,
Download,
Info
} from 'lucide-react';
import Card from '../../ui/Card/Card';
import { Button, Badge } from '../../ui';
import { useSustainabilityWidget } from '../../../api/hooks/sustainability';
import { useCurrentTenant } from '../../../stores/tenant.store';
interface SustainabilityWidgetProps {
days?: number;
onViewDetails?: () => void;
onExportReport?: () => void;
}
export const SustainabilityWidget: React.FC<SustainabilityWidgetProps> = ({
days = 30,
onViewDetails,
onExportReport
}) => {
const { t } = useTranslation(['sustainability', 'common']);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const { data, isLoading, error } = useSustainabilityWidget(tenantId, days, {
enabled: !!tenantId
});
const getSDGStatusColor = (status: string) => {
switch (status) {
case 'sdg_compliant':
return 'bg-green-500/10 text-green-600 border-green-500/20';
case 'on_track':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
case 'progressing':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
default:
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
}
};
const getSDGStatusLabel = (status: string) => {
const labels: Record<string, string> = {
sdg_compliant: t('sustainability:sdg.status.compliant', 'SDG Compliant'),
on_track: t('sustainability:sdg.status.on_track', 'On Track'),
progressing: t('sustainability:sdg.status.progressing', 'Progressing'),
baseline: t('sustainability:sdg.status.baseline', 'Baseline')
};
return labels[status] || status;
};
if (isLoading) {
return (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-32 bg-[var(--bg-secondary)] rounded"></div>
</div>
</Card>
);
}
if (error || !data) {
return (
<Card className="p-6">
<div className="text-center py-8">
<Leaf className="w-12 h-12 mx-auto mb-3 text-[var(--text-secondary)] opacity-50" />
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:errors.load_failed', 'Unable to load sustainability metrics')}
</p>
</div>
</Card>
);
}
return (
<Card className="overflow-hidden">
{/* Header */}
<div className="p-6 pb-4 border-b border-[var(--border-primary)] bg-gradient-to-r from-green-50/50 to-blue-50/50 dark:from-green-900/10 dark:to-blue-900/10">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/10 rounded-lg">
<Leaf className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:widget.title', 'Sustainability Impact')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:widget.subtitle', 'Environmental & SDG 12.3 Compliance')}
</p>
</div>
</div>
<div className={`px-3 py-1 rounded-full border text-xs font-medium ${getSDGStatusColor(data.sdg_status)}`}>
{getSDGStatusLabel(data.sdg_status)}
</div>
</div>
</div>
{/* SDG Progress Bar */}
<div className="p-6 pb-4 border-b border-[var(--border-primary)]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('sustainability:sdg.progress_label', 'SDG 12.3 Target Progress')}
</span>
<span className="text-sm font-bold text-[var(--color-primary)]">
{Math.round(data.sdg_progress)}%
</span>
</div>
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-500 relative overflow-hidden"
style={{ width: `${Math.min(data.sdg_progress, 100)}%` }}
>
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
</div>
<p className="text-xs text-[var(--text-secondary)] mt-2">
{t('sustainability:sdg.target_note', 'Target: 50% food waste reduction by 2030')}
</p>
</div>
{/* Key Metrics Grid */}
<div className="p-6 grid grid-cols-2 gap-4">
{/* Waste Reduction */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<TrendingDown className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-xs font-medium text-[var(--text-secondary)]">
{t('sustainability:metrics.waste_reduction', 'Waste Reduction')}
</span>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{Math.abs(data.waste_reduction_percentage).toFixed(1)}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{data.total_waste_kg.toFixed(0)} kg {t('common:saved', 'saved')}
</p>
</div>
{/* CO2 Impact */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Leaf className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-xs font-medium text-[var(--text-secondary)]">
{t('sustainability:metrics.co2_avoided', 'CO₂ Avoided')}
</span>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{data.co2_saved_kg.toFixed(0)} kg
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{data.trees_equivalent.toFixed(1)} {t('sustainability:metrics.trees', 'trees')}
</p>
</div>
{/* Water Saved */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-xs font-medium text-[var(--text-secondary)]">
{t('sustainability:metrics.water_saved', 'Water Saved')}
</span>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{(data.water_saved_liters / 1000).toFixed(1)} m³
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{data.water_saved_liters.toFixed(0)} {t('common:liters', 'liters')}
</p>
</div>
{/* Grant Programs */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Award className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-[var(--text-secondary)]">
{t('sustainability:metrics.grants_eligible', 'Grants Eligible')}
</span>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{data.grant_programs_ready}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('sustainability:metrics.programs', 'programs')}
</p>
</div>
</div>
{/* Financial Impact */}
<div className="px-6 pb-4">
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">
{t('sustainability:financial.potential_savings', 'Potential Monthly Savings')}
</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{data.financial_savings_eur.toFixed(2)}
</p>
</div>
<TreeDeciduous className="w-10 h-10 text-green-600/30 dark:text-green-400/30" />
</div>
</div>
</div>
{/* Actions */}
<div className="p-6 pt-4 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
<div className="flex items-center gap-2">
{onViewDetails && (
<Button
variant="outline"
size="sm"
onClick={onViewDetails}
className="flex-1"
>
<Info className="w-4 h-4 mr-1" />
{t('sustainability:actions.view_details', 'View Details')}
</Button>
)}
{onExportReport && (
<Button
variant="primary"
size="sm"
onClick={onExportReport}
className="flex-1"
>
<Download className="w-4 h-4 mr-1" />
{t('sustainability:actions.export_report', 'Export Report')}
</Button>
)}
</div>
<p className="text-xs text-[var(--text-secondary)] text-center mt-3">
{t('sustainability:widget.footer', 'Aligned with UN SDG 12.3 & EU Green Deal')}
</p>
</div>
</Card>
);
};
export default SustainabilityWidget;

View File

@@ -7,7 +7,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { useNotifications } from '../../../hooks/useNotifications';
import { useHasAccess } from '../../../hooks/useAccessControl';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { CountBadge } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { ThemeToggle } from '../../ui/ThemeToggle';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
@@ -258,13 +258,13 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
unreadCount > 0 && "text-[var(--color-warning)]"
)} />
{unreadCount > 0 && (
<Badge
<CountBadge
count={unreadCount}
max={99}
variant="error"
size="sm"
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-xs flex items-center justify-center"
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
overlay
/>
)}
</Button>

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