Improve the frontend and repository layer
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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