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;