diff --git a/apps/web/src/components/OfferSelector.tsx b/apps/web/src/components/OfferSelector.tsx index cb2fe97..31e8a04 100644 --- a/apps/web/src/components/OfferSelector.tsx +++ b/apps/web/src/components/OfferSelector.tsx @@ -165,47 +165,6 @@ export default function OfferSelector({ return `${numPrice.toFixed(2)} €/mois` } - // Get main price to display (short version for dropdown) - const getMainPrice = (offer: EnergyOffer): string => { - // Base offers - if (offer.offer_type === 'BASE' && offer.base_price !== undefined && offer.base_price !== null) { - return formatPrice(offer.base_price) - } - // HP/HC offers - if (offer.offer_type === 'HC_HP' && offer.hp_price !== undefined && offer.hp_price !== null) { - return `HP: ${formatPrice(offer.hp_price)}` - } - // Tempo offers - show blue HC as reference (most common) - if (offer.offer_type === 'TEMPO' && offer.tempo_blue_hc !== undefined && offer.tempo_blue_hc !== null) { - return `Bleu HC: ${formatPrice(offer.tempo_blue_hc)}` - } - // EJP offers - if (offer.offer_type === 'EJP' && offer.ejp_normal !== undefined && offer.ejp_normal !== null) { - return `Normal: ${formatPrice(offer.ejp_normal)}` - } - // Weekend offers - if (offer.offer_type === 'WEEKEND') { - if (offer.base_price !== undefined && offer.base_price !== null) { - return formatPrice(offer.base_price) - } - if (offer.hp_price !== undefined && offer.hp_price !== null) { - return `Semaine: ${formatPrice(offer.hp_price)}` - } - } - // Seasonal offers - if (offer.offer_type === 'SEASONAL' && offer.hp_price_winter !== undefined && offer.hp_price_winter !== null) { - return `Hiver HP: ${formatPrice(offer.hp_price_winter)}` - } - // Fallback to base_price or hp_price - if (offer.base_price !== undefined && offer.base_price !== null) { - return formatPrice(offer.base_price) - } - if (offer.hp_price !== undefined && offer.hp_price !== null) { - return `HP: ${formatPrice(offer.hp_price)}` - } - return '-' - } - // Get detailed prices for selected offer summary const getDetailedPrices = (offer: EnergyOffer): React.ReactNode => { const priceRows: React.ReactNode[] = [] diff --git a/apps/web/src/components/PageHeader.tsx b/apps/web/src/components/PageHeader.tsx index c188cb4..a17f638 100644 --- a/apps/web/src/components/PageHeader.tsx +++ b/apps/web/src/components/PageHeader.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { useLocation } from 'react-router-dom' import { TrendingUp, Sun, Calculator, Download, Lock, LayoutDashboard, Calendar, Zap, Users, AlertCircle, BookOpen, Settings as SettingsIcon, Key, Shield, FileText, Activity, Euro, Scale } from 'lucide-react' import { useQuery } from '@tanstack/react-query' +import toast from 'react-hot-toast' import { pdlApi } from '@/api/pdl' import { usePdlStore } from '@/stores/pdlStore' import { useDataFetchStore } from '@/stores/dataFetchStore' @@ -141,6 +142,27 @@ export default function PageHeader() { ? activePdls.filter((pdl: PDL) => pdl.has_consumption) : activePdls.filter((pdl: PDL) => !linkedProductionIds.has(pdl.id)) // Hide linked production PDLs globally + // Auto-select first PDL if none selected OR if current PDL is not in the displayed list + useEffect(() => { + if (displayedPdls.length > 0) { + // Check if current PDL is in the displayed list for this page + const currentPdlInList = displayedPdls.some(p => p.usage_point_id === selectedPdl) + + if (!selectedPdl || !currentPdlInList) { + const newPdl = displayedPdls[0] + // Show toast only if we're switching from an incompatible PDL (not on first load) + if (selectedPdl && !currentPdlInList) { + toast(`PDL changé automatiquement vers "${newPdl.name || newPdl.usage_point_id}"`, { + icon: '🔄', + duration: 4000, + }) + } + // Select the first available PDL for this page + setSelectedPdl(newPdl.usage_point_id) + } + } + }, [displayedPdls, selectedPdl, setSelectedPdl]) + return (
@@ -186,7 +208,6 @@ export default function PageHeader() { onChange={(e) => setSelectedPdl(e.target.value)} className="px-3 sm:px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-colors text-sm w-full sm:w-auto" > - {displayedPdls.map((pdl: PDL) => (
+ )} + {/* Pricing Grid */} {offerType === 'BASE' && (
@@ -160,6 +252,74 @@ export function OfferPricingCard({ selectedOffer }: OfferPricingCardProps) { )}
)} + + {/* Comparison selector */} + {onComparisonChange && compatibleOffers.length > 0 && ( +
+ + + {/* Offer list - inline within the card */} + {isDropdownOpen && ( +
+ {offersByProvider.map(({ provider, offers }) => ( +
+
+ {provider.name} +
+ {offers.map(offer => { + const isSelected = offer.id === comparisonOfferId + const isOriginal = offer.id === originalOffer?.id + return ( + + ) + })} +
+ ))} +
+ )} +
+ )}
) } diff --git a/apps/web/src/pages/ConsumptionEuro/index.tsx b/apps/web/src/pages/ConsumptionEuro/index.tsx index 3e5de4e..38ed617 100644 --- a/apps/web/src/pages/ConsumptionEuro/index.tsx +++ b/apps/web/src/pages/ConsumptionEuro/index.tsx @@ -1,12 +1,18 @@ -import { useState, useEffect, useMemo } from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Euro, BarChart3, Database, ArrowRight, AlertCircle } from 'lucide-react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { Euro, BarChart3, Database, ArrowRight, AlertCircle, Tag } from 'lucide-react' +import toast from 'react-hot-toast' import { usePdlStore } from '@/stores/pdlStore' +import { useDataFetchStore } from '@/stores/dataFetchStore' import { pdlApi } from '@/api/pdl' import { energyApi } from '@/api/energy' import { LoadingOverlay } from '@/components/LoadingOverlay' import { LoadingPlaceholder } from '@/components/LoadingPlaceholder' import { AnimatedSection } from '@/components/AnimatedSection' +import { useIsDemo } from '@/hooks/useIsDemo' +import { useUnifiedDataFetch } from '@/hooks/useUnifiedDataFetch' +import { logger } from '@/utils/logger' +import OfferSelector from '@/components/OfferSelector' import type { PDL } from '@/types/api' // Import hooks and components @@ -20,7 +26,11 @@ import type { SelectedOfferWithProvider } from './types/euro.types' export default function ConsumptionEuro() { const { selectedPdl: selectedPDL } = usePdlStore() + const { setIsLoading } = useDataFetchStore() const queryClient = useQueryClient() + const isDemo = useIsDemo() + const demoAutoFetchDone = useRef(false) + const lastAutoFetchPDL = useRef(null) // States const [dateRange, setDateRange] = useState<{ start: string; end: string } | null>(null) @@ -34,6 +44,7 @@ export default function ConsumptionEuro() { const [isLoadingExiting, setIsLoadingExiting] = useState(false) const [isInitializing, setIsInitializing] = useState(true) const [hasDataInCache, setHasDataInCache] = useState(false) + const [comparisonOfferId, setComparisonOfferId] = useState(null) // Detect dark mode useEffect(() => { @@ -57,7 +68,7 @@ export default function ConsumptionEuro() { return () => clearTimeout(timer) }, [selectedPDL]) - // Fetch PDLs + // Fetch PDLs - with short staleTime to ensure provider changes are reflected const { data: pdlsData } = useQuery({ queryKey: ['pdls'], queryFn: async () => { @@ -67,11 +78,20 @@ export default function ConsumptionEuro() { } return [] }, + staleTime: 30 * 1000, // 30 seconds - same as Dashboard for consistency }) const pdls = Array.isArray(pdlsData) ? pdlsData : [] const selectedPDLDetails = pdls.find(p => p.usage_point_id === selectedPDL) + // Hook for demo auto-fetch - uses the same unified fetch as the header + const { fetchAllData } = useUnifiedDataFetch({ + selectedPDL, + selectedPDLDetails, + allPDLs: pdls, + pageContext: 'consumption', + }) + // Fetch providers const { data: providersResponse } = useQuery({ queryKey: ['energy-providers'], @@ -104,6 +124,30 @@ export default function ConsumptionEuro() { } }, [selectedPDLDetails?.selected_offer_id, allOffers, providers]) + // Get comparison offer (for display only, doesn't change PDL's main offer) + const comparisonOfferWithProvider = useMemo((): SelectedOfferWithProvider | null => { + if (!comparisonOfferId) return null + + const offer = allOffers.find(o => o.id === comparisonOfferId) + if (!offer) return null + + const provider = providers.find(p => p.id === offer.provider_id) + + return { + ...offer, + providerName: provider?.name || 'Fournisseur inconnu' + } + }, [comparisonOfferId, allOffers, providers]) + + // The offer used for calculations (comparison if selected, otherwise the PDL's main offer) + const displayOffer = comparisonOfferWithProvider || selectedOfferWithProvider + + // Filter compatible offers for comparison (same power_kva as selected offer) + const compatibleOffers = useMemo(() => { + if (!selectedOfferWithProvider?.power_kva) return allOffers + return allOffers.filter(o => o.power_kva === selectedOfferWithProvider.power_kva) + }, [allOffers, selectedOfferWithProvider?.power_kva]) + // Check for cached detail data // Type for cache data interface CacheData { @@ -200,17 +244,60 @@ export default function ConsumptionEuro() { return () => clearInterval(interval) }, [selectedPDL, queryClient]) - // Use euro calculation hook + // Auto-fetch data for demo account + useEffect(() => { + // Reset demoAutoFetchDone when PDL changes + if (selectedPDL && selectedPDL !== lastAutoFetchPDL.current) { + demoAutoFetchDone.current = false + } + + if (isDemo && selectedPDL && selectedPDLDetails && !demoAutoFetchDone.current && !hasDataInCache && !isInitializing) { + logger.log('[DEMO] Auto-fetching consumption data for ConsumptionEuro') + demoAutoFetchDone.current = true + lastAutoFetchPDL.current = selectedPDL + // Small delay to ensure everything is mounted + setTimeout(async () => { + setIsLoading(true) + try { + await fetchAllData() + } finally { + setIsLoading(false) + } + }, 300) + } + }, [isDemo, selectedPDL, selectedPDLDetails, hasDataInCache, isInitializing, setIsLoading, fetchAllData]) + + // Use euro calculation hook - uses displayOffer for comparison feature const { yearlyCosts } = useConsumptionEuroCalcs({ selectedPDL, selectedPDLDetails, - selectedOffer: selectedOfferWithProvider, + selectedOffer: displayOffer, hcHpCalculationTrigger }) const hasOffer = !!selectedOfferWithProvider const hasCostData = yearlyCosts.length > 0 + // Mutation to update selected offer + const updateSelectedOfferMutation = useMutation({ + mutationFn: (selected_offer_id: string | null) => { + if (isDemo) { + return Promise.reject(new Error('Modifications désactivées en mode démo')) + } + if (!selectedPDLDetails) { + return Promise.reject(new Error('Aucun PDL sélectionné')) + } + return pdlApi.updateSelectedOffer(selectedPDLDetails.id, selected_offer_id) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pdls'] }) + toast.success('Offre tarifaire mise à jour') + }, + onError: (error: Error) => { + toast.error(error.message || 'Erreur lors de la mise à jour de l\'offre') + }, + }) + // Block rendering during initialization if (isInitializing) { return
@@ -229,25 +316,35 @@ export default function ConsumptionEuro() { return (
- {/* Warning if no offer selected */} + {/* Offer selector when no offer selected */} {!hasOffer && hasDataInCache && ( -
-
- +
+
+
+ +
-

- Aucune offre tarifaire sélectionnée -

-

- Pour calculer vos coûts en euros, vous devez d'abord sélectionner une offre tarifaire - dans le tableau de bord pour ce PDL. +

+ Sélectionnez votre offre tarifaire +

+

+ Pour calculer vos coûts en euros, choisissez votre offre d'électricité actuelle.

-
- - Allez dans le Dashboard et sélectionnez votre offre dans la carte du PDL -
+ { + updateSelectedOfferMutation.mutate(offerId) + }} + disabled={updateSelectedOfferMutation.isPending || isDemo} + /> + {isDemo && ( +

+ La sélection d'offre est désactivée en mode démo. +

+ )}
)} @@ -278,15 +375,6 @@ export default function ConsumptionEuro() {
)} - {/* Current offer info with pricing details */} - {hasDataInCache && hasOffer && selectedOfferWithProvider && ( - -
- -
-
- )} - {/* Statistics Section */}
@@ -301,7 +389,7 @@ export default function ConsumptionEuro() { }} >
- +

Statistiques de coûts

@@ -329,7 +417,7 @@ export default function ConsumptionEuro() {
@@ -346,9 +434,26 @@ export default function ConsumptionEuro() {
+ {/* Current offer info with pricing details - now below statistics with comparison selector */} + {hasDataInCache && hasOffer && displayOffer && ( + +
+ +
+
+ )} + {/* Charts Section */} -
+
setIsChartSectionExpanded(!isChartSectionExpanded)} diff --git a/apps/web/src/pages/ConsumptionKwh/components/AnnualCurve.tsx b/apps/web/src/pages/ConsumptionKwh/components/AnnualCurve.tsx index e8926a3..eaa5d37 100644 --- a/apps/web/src/pages/ConsumptionKwh/components/AnnualCurve.tsx +++ b/apps/web/src/pages/ConsumptionKwh/components/AnnualCurve.tsx @@ -24,6 +24,17 @@ interface AnnualCurveProps { isDarkMode: boolean } +// Graph colors - same as used in the chart lines +const graphColors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4'] + +// Convert hex to rgba for tab backgrounds +const hexToRgba = (hex: string, alpha: number) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + export function AnnualCurve({ chartData, isDarkMode }: AnnualCurveProps) { // If we have yearsByPeriod array from chartData, use it, otherwise create a single year const yearsData: YearData[] = chartData.yearsByPeriod || [{ @@ -160,17 +171,24 @@ export function AnnualCurve({ chartData, isDarkMode }: AnnualCurveProps) { {[...yearsData].reverse().map((yearData, idx) => { const originalIndex = yearsData.length - 1 - idx const isSelected = selectedYears.has(originalIndex) + const color = graphColors[idx % graphColors.length] return ( - toggleYearSelection(originalIndex)} - className="flex-1 min-w-[100px]" + className={`flex-1 min-w-[100px] px-4 py-2 text-base font-semibold rounded-lg border-2 transition-all duration-200 ${ + isSelected + ? 'shadow-md' + : 'text-gray-400 hover:text-gray-200 border-gray-700 hover:border-gray-600' + }`} + style={isSelected ? { + backgroundColor: hexToRgba(color, 0.125), + borderColor: color, + color: color, + } : undefined} > {yearData.label} - + ) })}
@@ -203,7 +221,7 @@ export function AnnualCurve({ chartData, isDarkMode }: AnnualCurveProps) {
{/* Display selected years chart */} -
+
{ + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + export function HcHpDistribution({ hcHpByYear, selectedPDLDetails }: HcHpDistributionProps) { const [selectedHcHpPeriod, setSelectedHcHpPeriod] = useState(0) @@ -55,18 +66,26 @@ export function HcHpDistribution({ hcHpByYear, selectedPDLDetails }: HcHpDistrib {hcHpByYear.map((yearData, index) => { // Extract the year from the end date const endYear = yearData.year.split(' - ')[1]?.split(' ').pop() || yearData.year + const color = graphColors[index % graphColors.length] + const isActive = selectedHcHpPeriod === index return ( - setSelectedHcHpPeriod(index)} - className="flex-1 min-w-[80px]" + className={`flex-1 min-w-[80px] px-4 py-2 text-base font-semibold rounded-lg border-2 transition-all duration-200 ${ + isActive + ? 'shadow-md' + : 'text-gray-400 hover:text-gray-200 border-gray-700 hover:border-gray-600' + }`} + style={isActive ? { + backgroundColor: hexToRgba(color, 0.125), + borderColor: color, + color: color, + } : undefined} > {endYear} - + ) })}
@@ -90,16 +109,22 @@ export function HcHpDistribution({ hcHpByYear, selectedPDLDetails }: HcHpDistrib const hpPercentage = yearData.totalKwh > 0 ? (yearData.hpKwh / yearData.totalKwh) * 100 : 0 const pieData = [ - { name: 'Heures Creuses (HC)', value: yearData.hcKwh, color: '#3b82f6' }, - { name: 'Heures Pleines (HP)', value: yearData.hpKwh, color: '#f97316' }, + { name: 'Heures Creuses (HC)', value: yearData.hcKwh, color: 'rgba(96, 165, 250, 0.6)' }, // blue-400 with transparency + { name: 'Heures Pleines (HP)', value: yearData.hpKwh, color: 'rgba(251, 146, 60, 0.6)' }, // orange-400 with transparency ] return ( -
+
-

- {yearData.year} -

+
+
+ + +
+

+ {yearData.year} +

+
{/* Heures Creuses */} -
+
-

Heures Creuses (HC)

+
+ +

Heures Creuses (HC)

+
{hcPercentage.toFixed(1)}%
-

+

{yearData.hcKwh.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} kWh

{/* Heures Pleines */} -
+
-

Heures Pleines (HP)

+
+ +

Heures Pleines (HP)

+
{hpPercentage.toFixed(1)}%
-

+

{yearData.hpKwh.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} kWh

- - {/* Visual bar */} -
-
-
-
-
-
diff --git a/apps/web/src/pages/ConsumptionKwh/components/MonthlyHcHp.tsx b/apps/web/src/pages/ConsumptionKwh/components/MonthlyHcHp.tsx index d5b8e30..f959a53 100644 --- a/apps/web/src/pages/ConsumptionKwh/components/MonthlyHcHp.tsx +++ b/apps/web/src/pages/ConsumptionKwh/components/MonthlyHcHp.tsx @@ -24,6 +24,17 @@ interface MonthlyHcHpProps { isDarkMode: boolean } +// Graph colors for tabs - matching the HC bars in the chart +const graphColors = ['#3B82F6', '#93C5FD', '#60A5FA', '#2563EB'] + +// Convert hex to rgba for tab backgrounds +const hexToRgba = (hex: string, alpha: number) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + export function MonthlyHcHp({ monthlyHcHpByYear, selectedPDLDetails, isDarkMode }: MonthlyHcHpProps) { // Track selected years with a Set (allows multiple selections) // Default to the most recent year (find the highest year value) @@ -156,19 +167,26 @@ export function MonthlyHcHp({ monthlyHcHpByYear, selectedPDLDetails, isDarkMode
{/* Tabs on the left - Allow multiple selection */}
- {monthlyHcHpByYear.map((yearData, _idx) => { - const isSelected = selectedYears.has(_idx) + {monthlyHcHpByYear.map((yearData, idx) => { + const isSelected = selectedYears.has(idx) + const color = graphColors[idx % graphColors.length] return ( - toggleYearSelection(_idx)} - className="flex-1 min-w-[80px]" + onClick={() => toggleYearSelection(idx)} + className={`flex-1 min-w-[80px] px-4 py-2 text-base font-semibold rounded-lg border-2 transition-all duration-200 ${ + isSelected + ? 'shadow-md' + : 'text-gray-400 hover:text-gray-200 border-gray-700 hover:border-gray-600' + }`} + style={isSelected ? { + backgroundColor: hexToRgba(color, 0.125), + borderColor: color, + color: color, + } : undefined} > {yearData.year} - + ) })}
@@ -201,7 +219,7 @@ export function MonthlyHcHp({ monthlyHcHpByYear, selectedPDLDetails, isDarkMode
{/* Display selected years chart */} -
+
{/* Show data availability warnings for selected years */} {selectedYearsData.some(year => year.dataAvailable !== undefined && year.dataAvailable < 350) && (
diff --git a/apps/web/src/pages/ConsumptionKwh/components/PowerPeaks.tsx b/apps/web/src/pages/ConsumptionKwh/components/PowerPeaks.tsx index 2e4f0f9..3eb6c4c 100644 --- a/apps/web/src/pages/ConsumptionKwh/components/PowerPeaks.tsx +++ b/apps/web/src/pages/ConsumptionKwh/components/PowerPeaks.tsx @@ -20,6 +20,17 @@ interface PowerPeaksProps { isDarkMode: boolean } +// Graph colors - same as used in the chart lines +const graphColors = ['#EF4444', '#F59E0B', '#10B981', '#8B5CF6', '#EC4899', '#06B6D4'] + +// Convert hex to rgba for tab backgrounds +const hexToRgba = (hex: string, alpha: number) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + export function PowerPeaks({ powerByYearData, selectedPDLDetails, @@ -151,17 +162,24 @@ export function PowerPeaks({ {[...powerByYearData].reverse().map((data, idx) => { const originalIndex = powerByYearData.length - 1 - idx const isSelected = selectedYears.has(originalIndex) + const color = graphColors[idx % graphColors.length] return ( - toggleYearSelection(originalIndex)} - className="flex-1 min-w-[100px]" + className={`flex-1 min-w-[100px] px-4 py-2 text-base font-semibold rounded-lg border-2 transition-all duration-200 ${ + isSelected + ? 'shadow-md' + : 'text-gray-400 hover:text-gray-200 border-gray-700 hover:border-gray-600' + }`} + style={isSelected ? { + backgroundColor: hexToRgba(color, 0.125), + borderColor: color, + color: color, + } : undefined} > {data.label} - + ) })}
@@ -194,7 +212,7 @@ export function PowerPeaks({
{/* Display selected years graph */} -
+
-
+
diff --git a/apps/web/src/pages/ConsumptionKwh/components/YearlyStatCards.tsx b/apps/web/src/pages/ConsumptionKwh/components/YearlyStatCards.tsx index ede7101..5bcea5f 100644 --- a/apps/web/src/pages/ConsumptionKwh/components/YearlyStatCards.tsx +++ b/apps/web/src/pages/ConsumptionKwh/components/YearlyStatCards.tsx @@ -1,4 +1,4 @@ -import { Download } from 'lucide-react' +import { Download, Zap, TrendingDown, TrendingUp, Calendar } from 'lucide-react' import toast from 'react-hot-toast' interface YearlyStatCardsProps { @@ -9,6 +9,26 @@ interface YearlyStatCardsProps { consumptionData: any } +// Card colors - dark background with colored borders (matching Production style) +const cardColors = [ + { + border: 'border-blue-500', + icon: 'text-blue-400', + }, + { + border: 'border-emerald-500', + icon: 'text-emerald-400', + }, + { + border: 'border-purple-500', + icon: 'text-purple-400', + }, + { + border: 'border-amber-500', + icon: 'text-amber-400', + }, +] + export function YearlyStatCards({ chartData, consumptionData }: YearlyStatCardsProps) { const handleExportYear = (yearData: any) => { const intervalLength = consumptionData?.meter_reading?.reading_type?.interval_length || 'P1D' @@ -106,7 +126,8 @@ export function YearlyStatCards({ chartData, consumptionData }: YearlyStatCardsP ? 'lg:grid-cols-3 xl:grid-cols-4' : 'lg:grid-cols-2' }`}> - {chartData.byYear.map((yearData) => { + {chartData.byYear.map((yearData, index) => { + const colors = cardColors[index % cardColors.length] const startDateFormatted = yearData.startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', @@ -118,33 +139,65 @@ export function YearlyStatCards({ chartData, consumptionData }: YearlyStatCardsP year: 'numeric' }) + // Calculate year-over-year comparison + const previousYear = chartData.byYear[index + 1] + const yoyChange = previousYear + ? ((yearData.consommation - previousYear.consommation) / previousYear.consommation) * 100 + : null + return (
-

- {yearData.year} -

+
+ +

+ {yearData.year} +

+
-

- {startDateFormatted} - {endDateFormatted} +

+ + {startDateFormatted} → {endDateFormatted}

-

- {(yearData.consommation / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 2 })} kWh +

+ {(yearData.consommation / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })} kWh

+ + {/* Year-over-year comparison */} + {yoyChange !== null && ( +
+ {yoyChange > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-red-600 dark:text-red-400' + : 'text-emerald-600 dark:text-emerald-400' + }`}> + {yoyChange > 0 ? '+' : ''}{yoyChange.toFixed(1)}% + + + vs année précédente + +
+ )}
) diff --git a/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionData.ts b/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionData.ts index 241d076..6947ddf 100644 --- a/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionData.ts +++ b/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionData.ts @@ -11,7 +11,7 @@ type EnedisApiResponse = APIResponse export function useConsumptionData(selectedPDL: string, dateRange: DateRange | null, _detailDateRange: DateRange | null) { const queryClient = useQueryClient() - // Fetch PDLs + // Fetch PDLs - with short staleTime to ensure provider changes are reflected const { data: pdlsData } = useQuery({ queryKey: ['pdls'], queryFn: async () => { @@ -21,6 +21,7 @@ export function useConsumptionData(selectedPDL: string, dateRange: DateRange | n } return [] }, + staleTime: 30 * 1000, // 30 seconds - same as Dashboard for consistency }) const pdls = Array.isArray(pdlsData) ? pdlsData : [] diff --git a/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionFetch.ts b/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionFetch.ts index 5d9bb47..528097d 100644 --- a/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionFetch.ts +++ b/apps/web/src/pages/ConsumptionKwh/hooks/useConsumptionFetch.ts @@ -8,11 +8,11 @@ import toast from 'react-hot-toast' import type { PDL } from '@/types/api' import type { DateRange, LoadingProgress } from '../types/consumption.types' -interface UseConsumptionFetchParams { +export interface UseConsumptionFetchParams { selectedPDL: string selectedPDLDetails: PDL | undefined setDateRange: (value: DateRange | null) => void - setIsChartsExpanded: (value: boolean) => void + setIsChartSectionExpanded: (value: boolean) => void setIsDetailSectionExpanded: (value: boolean) => void setIsStatsSectionExpanded: (value: boolean) => void setIsPowerSectionExpanded: (value: boolean) => void @@ -23,14 +23,13 @@ interface UseConsumptionFetchParams { setIsLoadingDetailed: (value: boolean) => void setLoadingProgress: (value: LoadingProgress) => void setHcHpCalculationTrigger: (updater: (prev: number) => number) => void - setIsClearingCache: (value: boolean) => void } export function useConsumptionFetch({ selectedPDL, selectedPDLDetails, setDateRange, - setIsChartsExpanded, + setIsChartSectionExpanded, setIsDetailSectionExpanded, setIsStatsSectionExpanded, setIsPowerSectionExpanded, @@ -41,7 +40,6 @@ export function useConsumptionFetch({ setIsLoadingDetailed, setLoadingProgress, setHcHpCalculationTrigger, - setIsClearingCache, }: UseConsumptionFetchParams) { const queryClient = useQueryClient() @@ -63,7 +61,7 @@ export function useConsumptionFetch({ queryClient.invalidateQueries({ queryKey: ['maxPower', selectedPDL] }) // Collapse all sections before fetching new data - setIsChartsExpanded(false) + setIsChartSectionExpanded(false) setIsDetailSectionExpanded(false) setIsStatsSectionExpanded(false) setIsPowerSectionExpanded(false) @@ -212,7 +210,7 @@ export function useConsumptionFetch({ setLoadingProgress({ current: 1, total: 1, currentRange: 'Terminé !' }) // Expand all sections to show the data - setIsChartsExpanded(true) + setIsChartSectionExpanded(true) setIsDetailSectionExpanded(true) setIsStatsSectionExpanded(true) setIsPowerSectionExpanded(true) @@ -378,7 +376,7 @@ export function useConsumptionFetch({ selectedPDLDetails, allPDLs, setDateRange, - setIsChartsExpanded, + setIsChartSectionExpanded, setIsDetailSectionExpanded, setIsStatsSectionExpanded, setIsPowerSectionExpanded, @@ -393,7 +391,6 @@ export function useConsumptionFetch({ ]) const clearCache = useCallback(async () => { - setIsClearingCache(true) try { // Clear server-side cache for all consumption data FIRST await adminApi.clearAllConsumptionCache() @@ -422,10 +419,8 @@ export function useConsumptionFetch({ }, 1000) } catch (error: any) { toast.error(`Erreur lors de la suppression du cache: ${error.message}`) - } finally { - setIsClearingCache(false) } - }, [setIsClearingCache, queryClient]) + }, [queryClient]) return { fetchConsumptionData, diff --git a/apps/web/src/pages/ConsumptionKwh/index.tsx b/apps/web/src/pages/ConsumptionKwh/index.tsx index 8905097..3e6a519 100644 --- a/apps/web/src/pages/ConsumptionKwh/index.tsx +++ b/apps/web/src/pages/ConsumptionKwh/index.tsx @@ -1,10 +1,16 @@ -import { useState, useEffect, useMemo } from 'react' -import { TrendingUp, BarChart3, Database, ArrowRight } from 'lucide-react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { Zap, TrendingUp, BarChart3, Database, ArrowRight, LineChart } from 'lucide-react' import { usePdlStore } from '@/stores/pdlStore' +import { useDataFetchStore } from '@/stores/dataFetchStore' import { logger } from '@/utils/logger' import { LoadingOverlay } from '@/components/LoadingOverlay' import { LoadingPlaceholder } from '@/components/LoadingPlaceholder' import { AnimatedSection } from '@/components/AnimatedSection' +import { useIsDemo } from '@/hooks/useIsDemo' +import { useUnifiedDataFetch } from '@/hooks/useUnifiedDataFetch' +import { useQuery } from '@tanstack/react-query' +import { pdlApi } from '@/api/pdl' +import type { PDL } from '@/types/api' // Import custom hooks import { useConsumptionData } from './hooks/useConsumptionData' @@ -13,7 +19,6 @@ import { useConsumptionCalcs } from './hooks/useConsumptionCalcs' // Import components import { InfoBlock } from './components/InfoBlock' -import { ConfirmModal } from './components/ConfirmModal' import { YearlyConsumption } from './components/YearlyConsumption' import { HcHpDistribution } from './components/HcHpDistribution' import { YearlyStatCards } from './components/YearlyStatCards' @@ -25,11 +30,28 @@ import { PowerPeaks } from './components/PowerPeaks' // Renamed from Consumption to ConsumptionKwh export default function ConsumptionKwh() { const { selectedPdl: selectedPDL, setSelectedPdl: setSelectedPDL } = usePdlStore() + const { setIsLoading } = useDataFetchStore() + const isDemo = useIsDemo() + const demoAutoFetchDone = useRef(false) + const lastAutoFetchPDL = useRef(null) + + // Get PDL list for unified fetch - with short staleTime to ensure provider changes are reflected + const { data: pdlsResponse } = useQuery({ + queryKey: ['pdls'], + queryFn: async () => { + const response = await pdlApi.list() + if (response.success && Array.isArray(response.data)) { + return response.data as PDL[] + } + return [] + }, + staleTime: 30 * 1000, // 30 seconds - same as Dashboard for consistency + }) + const allPDLs: PDL[] = Array.isArray(pdlsResponse) ? pdlsResponse : [] + const selectedPDLForFetch = allPDLs.find(p => p.usage_point_id === selectedPDL) // States - const [, setIsClearingCache] = useState(false) - const [showConfirmModal, setShowConfirmModal] = useState(false) - const [isChartsExpanded, setIsChartsExpanded] = useState(false) + const [isChartSectionExpanded, setIsChartSectionExpanded] = useState(false) const [dateRange, setDateRange] = useState<{start: string, end: string} | null>(null) const [isPowerSectionExpanded, setIsPowerSectionExpanded] = useState(true) const [isStatsSectionExpanded, setIsStatsSectionExpanded] = useState(true) @@ -37,7 +59,6 @@ export default function ConsumptionKwh() { const [detailWeekOffset, setDetailWeekOffset] = useState(0) const [loadingProgress, setLoadingProgress] = useState({ current: 0, total: 0, currentRange: '' }) const [isLoadingDetailed, setIsLoadingDetailed] = useState(false) - const [, setIsLoadingDaily] = useState(false) const [dailyLoadingComplete, setDailyLoadingComplete] = useState(false) const [powerLoadingComplete, setPowerLoadingComplete] = useState(false) const [allLoadingComplete, setAllLoadingComplete] = useState(false) @@ -47,13 +68,13 @@ export default function ConsumptionKwh() { const [isDarkMode, setIsDarkMode] = useState(false) const [isInitialLoadingFromCache, setIsInitialLoadingFromCache] = useState(false) const [isLoadingExiting, setIsLoadingExiting] = useState(false) - const [isInfoSectionExpanded, setIsInfoSectionExpanded] = useState(true) + const [isInfoExpanded, setIsInfoExpanded] = useState(true) const [isInitializing, setIsInitializing] = useState(true) // Reset all display states when PDL changes useEffect(() => { logger.log('[Consumption] PDL changed, resetting display states') - setIsChartsExpanded(false) + setIsChartSectionExpanded(false) setIsStatsSectionExpanded(false) setIsDetailSectionExpanded(false) setIsPowerSectionExpanded(false) @@ -65,7 +86,7 @@ export default function ConsumptionKwh() { setLoadingProgress({ current: 0, total: 0, currentRange: '' }) setIsInitialLoadingFromCache(false) setIsLoadingExiting(false) - setIsInfoSectionExpanded(true) + setIsInfoExpanded(true) setIsInitializing(true) }, [selectedPDL]) @@ -168,11 +189,11 @@ export default function ConsumptionKwh() { detailDateRange }) - const { clearCache } = useConsumptionFetch({ + useConsumptionFetch({ selectedPDL, selectedPDLDetails, setDateRange, - setIsChartsExpanded, + setIsChartSectionExpanded, setIsDetailSectionExpanded, setIsStatsSectionExpanded, setIsPowerSectionExpanded, @@ -183,7 +204,14 @@ export default function ConsumptionKwh() { setIsLoadingDetailed, setLoadingProgress, setHcHpCalculationTrigger, - setIsClearingCache, + }) + + // Hook for demo auto-fetch - uses the same unified fetch as the header + const { fetchAllData } = useUnifiedDataFetch({ + selectedPDL, + selectedPDLDetails: selectedPDLForFetch, + allPDLs, + pageContext: 'consumption', }) // NOTE: Fetch function registration is now handled by the unified hook in PageHeader @@ -249,6 +277,29 @@ export default function ConsumptionKwh() { return () => clearInterval(interval) }, [selectedPDL]) // Re-run when PDL changes + // Auto-fetch data for demo account + useEffect(() => { + // Reset demoAutoFetchDone when PDL changes + if (selectedPDL && selectedPDL !== lastAutoFetchPDL.current) { + demoAutoFetchDone.current = false + } + + if (isDemo && selectedPDL && selectedPDLForFetch && !demoAutoFetchDone.current && !hasDataInCache && !isInitializing) { + logger.log('[DEMO] Auto-fetching consumption data for demo account') + demoAutoFetchDone.current = true + lastAutoFetchPDL.current = selectedPDL + // Small delay to ensure everything is mounted + setTimeout(async () => { + setIsLoading(true) + try { + await fetchAllData() + } finally { + setIsLoading(false) + } + }, 300) + } + }, [isDemo, selectedPDL, selectedPDLForFetch, hasDataInCache, isInitializing, setIsLoading, fetchAllData]) + // Auto-set selectedPDL when there's only one active PDL useEffect(() => { if (activePdls.length > 0 && !selectedPDL) { @@ -270,7 +321,6 @@ export default function ConsumptionKwh() { // Track loading completion useEffect(() => { if (consumptionData && !isLoadingConsumption) { - setIsLoadingDaily(false) setDailyLoadingComplete(true) } }, [consumptionData, isLoadingConsumption]) @@ -284,7 +334,7 @@ export default function ConsumptionKwh() { useEffect(() => { if (dailyLoadingComplete && powerLoadingComplete && !isLoadingDetailed) { setAllLoadingComplete(true) - setIsChartsExpanded(true) + setIsChartSectionExpanded(true) setIsStatsSectionExpanded(true) setIsDetailSectionExpanded(true) setIsPowerSectionExpanded(true) @@ -341,7 +391,7 @@ export default function ConsumptionKwh() { setDailyLoadingComplete(true) setPowerLoadingComplete(true) setAllLoadingComplete(true) - setIsChartsExpanded(true) + setIsChartSectionExpanded(true) setIsStatsSectionExpanded(true) setIsDetailSectionExpanded(true) setIsPowerSectionExpanded(true) @@ -413,10 +463,10 @@ export default function ConsumptionKwh() { if (allLoadingComplete) { logger.log('[Loading] ✓ All loading complete - expanding sections, collapsing info') setIsStatsSectionExpanded(true) - setIsChartsExpanded(true) + setIsChartSectionExpanded(true) setIsDetailSectionExpanded(true) setIsPowerSectionExpanded(true) - setIsInfoSectionExpanded(false) // Collapse info section when data is loaded + setIsInfoExpanded(false) // Collapse info section when data is loaded } }, [allLoadingComplete]) @@ -433,14 +483,6 @@ export default function ConsumptionKwh() { } }, [isLoadingDetailed, hcHpByYear.length]) - const confirmClearCache = async () => { - setShowConfirmModal(false) - await clearCache() - } - - // For now, return the simplified version with working components - // The rest of the components will be added incrementally - // Block rendering during initialization to prevent flash of content if (isInitializing) { return
@@ -511,7 +553,7 @@ export default function ConsumptionKwh() { }} >
- +

Statistiques de consommation

@@ -562,25 +604,25 @@ export default function ConsumptionKwh() { }`} onClick={() => { if (allLoadingComplete) { - setIsChartsExpanded(!isChartsExpanded) + setIsChartSectionExpanded(!isChartSectionExpanded) } }} >
- +

Graphiques de consommation

- {isChartsExpanded ? ( + {isChartSectionExpanded ? ( Réduire ) : ( Développer )}
- {isChartsExpanded && allLoadingComplete && ( + {isChartSectionExpanded && allLoadingComplete && (
{/* Yearly Consumption by Month */}
- +

Courbe de charge détaillée

@@ -707,7 +749,7 @@ export default function ConsumptionKwh() { }} >
- +

Pics de puissance maximale

@@ -744,15 +786,8 @@ export default function ConsumptionKwh() { {/* Info Block Component - Always visible, collapsible */} setIsInfoSectionExpanded(!isInfoSectionExpanded)} - /> - - {/* Confirm Modal Component */} - setIsInfoExpanded(!isInfoExpanded)} />
) diff --git a/apps/web/src/pages/Production/components/AnnualProductionCurve.tsx b/apps/web/src/pages/Production/components/AnnualProductionCurve.tsx index 1cd4692..7cccc2f 100644 --- a/apps/web/src/pages/Production/components/AnnualProductionCurve.tsx +++ b/apps/web/src/pages/Production/components/AnnualProductionCurve.tsx @@ -4,6 +4,17 @@ import { Download, ZoomOut } from 'lucide-react' import toast from 'react-hot-toast' import { ModernButton } from './ModernButton' +// Graph colors for production (green-themed to match production) +const graphColors = ['#10B981', '#3B82F6', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4'] + +// Helper function to convert hex to rgba +const hexToRgba = (hex: string, alpha: number) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + interface MonthData { month: string monthLabel: string @@ -139,7 +150,7 @@ export function AnnualProductionCurve({ chartData, isDarkMode }: AnnualProductio .filter((yearData): yearData is YearData => yearData !== undefined && yearData.byMonth !== undefined) .sort((a, b) => b.label.localeCompare(a.label)) - const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4'] + const colors = graphColors // Get display data based on zoom const displayData = zoomDomain @@ -159,17 +170,24 @@ export function AnnualProductionCurve({ chartData, isDarkMode }: AnnualProductio {[...yearsData].reverse().map((yearData, idx) => { const originalIndex = yearsData.length - 1 - idx const isSelected = selectedYears.has(originalIndex) + const color = graphColors[idx % graphColors.length] return ( - toggleYearSelection(originalIndex)} - className="flex-1 min-w-[100px]" + className={`flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium rounded-lg border-2 transition-all duration-200 ${ + isSelected + ? 'shadow-md' + : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50' + }`} + style={isSelected ? { + backgroundColor: hexToRgba(color, 0.125), + borderColor: color, + color: color, + } : undefined} > {yearData.label} - + ) })}
@@ -202,7 +220,7 @@ export function AnnualProductionCurve({ chartData, isDarkMode }: AnnualProductio
{/* Display selected years chart */} -
+
-
+
@@ -52,7 +52,7 @@ export function YearlyProduction({ chartData, isDarkMode }: YearlyProductionProp tickFormatter={(value) => `${(value / 1000).toFixed(0)} kWh`} /> {chartData.years.map((year, index) => { - const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] + const colors = ['#10B981', '#3B82F6', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4'] return ( { const intervalLength = productionData?.meter_reading?.reading_type?.interval_length || 'P1D' @@ -106,7 +138,8 @@ export function YearlyProductionCards({ chartData, productionData }: YearlyProdu ? 'lg:grid-cols-3 xl:grid-cols-4' : 'lg:grid-cols-2' }`}> - {chartData.byYear.map((yearData) => { + {chartData.byYear.map((yearData, index) => { + const colors = cardColors[index % cardColors.length] const startDateFormatted = yearData.startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', @@ -118,36 +151,65 @@ export function YearlyProductionCards({ chartData, productionData }: YearlyProdu year: 'numeric' }) + // Calculate year-over-year change + const previousYearData = chartData.byYear.find((y: any) => + parseInt(y.year) === parseInt(yearData.year) - 1 + ) + const yoyChange = previousYearData + ? ((yearData.production - previousYearData.production) / previousYearData.production) * 100 + : null + return (
-

- {yearData.year} -

+
+ +

+ {yearData.year} +

+
-

- {startDateFormatted} - {endDateFormatted} +

+ 📅 {startDateFormatted} → {endDateFormatted}

-

- {(yearData.production / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 2 })} kWh -

-

- {yearData.production.toLocaleString('fr-FR')} {chartData.unit === 'W' ? 'W' : 'Wh'} +

+ {(yearData.production / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })} kWh

+ + {/* Year-over-year comparison */} + {yoyChange !== null && ( +
+ {yoyChange >= 0 ? ( + + ) : ( + + )} + = 0 + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-red-600 dark:text-red-400' + }`}> + {yoyChange >= 0 ? '+' : ''}{yoyChange.toFixed(1)}% + + + vs année précédente + +
+ )}
) diff --git a/apps/web/src/pages/Production/hooks/useProductionData.ts b/apps/web/src/pages/Production/hooks/useProductionData.ts index ea552fc..8397b9c 100644 --- a/apps/web/src/pages/Production/hooks/useProductionData.ts +++ b/apps/web/src/pages/Production/hooks/useProductionData.ts @@ -8,7 +8,7 @@ import type { DateRange } from '../types/production.types' export function useProductionData(selectedPDL: string, dateRange: DateRange | null, _detailDateRange: DateRange | null) { const queryClient = useQueryClient() - // Fetch PDLs + // Fetch PDLs - with short staleTime to ensure provider changes are reflected const { data: pdlsData } = useQuery({ queryKey: ['pdls'], queryFn: async () => { @@ -18,6 +18,7 @@ export function useProductionData(selectedPDL: string, dateRange: DateRange | nu } return [] }, + staleTime: 30 * 1000, // 30 seconds - same as Dashboard for consistency }) const pdls = Array.isArray(pdlsData) ? pdlsData : [] diff --git a/apps/web/src/pages/Production/index.tsx b/apps/web/src/pages/Production/index.tsx index 2e5d373..6ab8546 100644 --- a/apps/web/src/pages/Production/index.tsx +++ b/apps/web/src/pages/Production/index.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useMemo } from 'react' -import { BarChart3, Database, ArrowRight, Info } from 'lucide-react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { Zap, BarChart3, Database, ArrowRight, Info, LineChart } from 'lucide-react' import { usePdlStore } from '@/stores/pdlStore' +import { useDataFetchStore } from '@/stores/dataFetchStore' import type { PDL } from '@/types/api' import { logger } from '@/utils/logger' import { useQuery } from '@tanstack/react-query' @@ -8,6 +9,8 @@ import { pdlApi } from '@/api/pdl' import { LoadingOverlay } from '@/components/LoadingOverlay' import { LoadingPlaceholder } from '@/components/LoadingPlaceholder' import { AnimatedSection } from '@/components/AnimatedSection' +import { useIsDemo } from '@/hooks/useIsDemo' +import { useUnifiedDataFetch } from '@/hooks/useUnifiedDataFetch' // Import custom hooks import { useProductionData } from './hooks/useProductionData' @@ -22,6 +25,10 @@ import { DetailedCurve } from '@/components/DetailedCurve' export default function Production() { const { selectedPdl: selectedPDL, setSelectedPdl: setSelectedPDL } = usePdlStore() + const { setIsLoading } = useDataFetchStore() + const isDemo = useIsDemo() + const demoAutoFetchDone = useRef(false) + const lastAutoFetchPDL = useRef(null) // States const [, setIsClearingCache] = useState(false) @@ -56,6 +63,8 @@ export default function Production() { setIsLoadingExiting(false) setIsInfoSectionExpanded(true) setIsInitializing(true) + setDataLimitWarning(null) // Reset warning while loading new PDL + setHasDataInCache(false) // Reset cache state for new PDL }, [selectedPDL]) // End initialization after cache has time to hydrate @@ -225,28 +234,112 @@ export default function Production() { setIsClearingCache, }) + // Hook for demo auto-fetch + const { fetchAllData } = useUnifiedDataFetch({ + selectedPDL, + selectedPDLDetails, + allPDLs: pdls, + pageContext: 'production', + }) + // Check if ANY production data is in cache - // Now uses single cache key format: ['productionDetail', pdl] - const hasDataInCache = useMemo(() => { + // Use state instead of useMemo for proper reactivity to cache updates + const [hasDataInCache, setHasDataInCache] = useState(false) + + // Check cache on mount and when PDL changes + // IMPORTANT: Wait for pdls to be loaded so actualProductionPDL is correct + useEffect(() => { + // Don't check cache until PDLs are loaded + if (!pdls.length) { + logger.log('[Cache Detection] Waiting for PDLs to load...') + return + } + const pdlToCheck = actualProductionPDL || selectedPDL if (!pdlToCheck) { - logger.log('[Cache Detection] No PDL selected') + setHasDataInCache(false) + return + } + + logger.log('[Cache Detection] Checking cache for PDL:', pdlToCheck, '(actualProductionPDL:', actualProductionPDL, ', selectedPDL:', selectedPDL, ')') + + const checkCache = () => { + const cachedData = queryClient.getQueryData(['productionDetail', pdlToCheck]) as any + if (cachedData?.data?.meter_reading?.interval_reading?.length > 0) { + const readings = cachedData.data.meter_reading.interval_reading + logger.log('[Cache Detection] ✓ Production cache found!', readings.length, 'points') + setHasDataInCache(true) + return true + } return false } - logger.log('[Cache Detection] Checking production cache for PDL:', pdlToCheck) + // Initial check + if (!checkCache()) { + logger.log('[Cache Detection] ✗ No production cache found for PDL:', pdlToCheck) + setHasDataInCache(false) + } + }, [actualProductionPDL, selectedPDL, queryClient, pdls.length]) + + // Poll for cache updates (catches data fetched by header or demo auto-fetch) + // IMPORTANT: Wait for pdls to be loaded so actualProductionPDL is correct + useEffect(() => { + // Don't poll until PDLs are loaded + if (!pdls.length) return + + const pdlToCheck = actualProductionPDL || selectedPDL + if (!pdlToCheck || hasDataInCache) return + + logger.log('[Cache Poll] Starting poll for PDL:', pdlToCheck) + + let pollCount = 0 + const maxPolls = 20 // 10 seconds total + + const interval = setInterval(() => { + pollCount++ + + const cachedData = queryClient.getQueryData(['productionDetail', pdlToCheck]) as any + if (cachedData?.data?.meter_reading?.interval_reading?.length > 0) { + logger.log('[Cache Poll] ✓ Production cache now available!') + setHasDataInCache(true) + clearInterval(interval) + return + } + + if (pollCount >= maxPolls) { + logger.log('[Cache Poll] Max polls reached, stopping') + clearInterval(interval) + } + }, 500) + + return () => clearInterval(interval) + }, [actualProductionPDL, selectedPDL, queryClient, hasDataInCache, pdls.length]) - // Check the single cache entry for this PDL - const cachedData = queryClient.getQueryData(['productionDetail', pdlToCheck]) as any - if (cachedData?.data?.meter_reading?.interval_reading?.length > 0) { - const readings = cachedData.data.meter_reading.interval_reading - logger.log('[Cache Detection] ✓ Production cache found!', readings.length, 'points') - return true + // Auto-fetch data for demo account + useEffect(() => { + // Reset demoAutoFetchDone when PDL changes + if (selectedPDL && selectedPDL !== lastAutoFetchPDL.current) { + demoAutoFetchDone.current = false } - logger.log('[Cache Detection] ✗ No production cache found') - return false - }, [actualProductionPDL, selectedPDL, queryClient]) + // Wait for PDLs to be loaded before auto-fetching + if (!pdls.length) return + + if (isDemo && selectedPDL && selectedPDLDetails && !demoAutoFetchDone.current && !hasDataInCache && !isInitializing) { + logger.log('[DEMO] Auto-fetching production data for demo account, PDL:', selectedPDL, 'actualProductionPDL:', actualProductionPDL) + demoAutoFetchDone.current = true + lastAutoFetchPDL.current = selectedPDL + // Small delay to ensure everything is mounted + setTimeout(async () => { + setIsLoading(true) + try { + await fetchAllData() + } finally { + setIsLoading(false) + } + }, 300) + } + }, [isDemo, selectedPDL, selectedPDLDetails, hasDataInCache, isInitializing, setIsLoading, fetchAllData, pdls.length, actualProductionPDL]) // Auto-set selectedPDL when there's only one available (non-linked) PDL useEffect(() => { @@ -255,19 +348,33 @@ export default function Production() { } }, [availableProductionPdls, selectedPDL, setSelectedPDL]) - // Check data limits + // Check data limits - only after PDLs are loaded useEffect(() => { + // Wait for PDLs to be loaded before checking + if (!pdls.length) return + if (selectedPDLDetails) { // Don't show warning if PDL has linked production const hasLinkedProduction = selectedPDLDetails.has_consumption && selectedPDLDetails.linked_production_pdl_id + logger.log('[Production] Data limits check:', { + pdl: selectedPDL, + has_production: selectedPDLDetails.has_production, + has_consumption: selectedPDLDetails.has_consumption, + linked_production_pdl_id: selectedPDLDetails.linked_production_pdl_id, + hasLinkedProduction, + }) + if (!selectedPDLDetails.has_production && !hasLinkedProduction) { setDataLimitWarning("Ce PDL n'a pas l'option production activée.") } else { setDataLimitWarning(null) } + } else if (selectedPDL) { + // PDL selected but details not found yet - clear warning while loading + setDataLimitWarning(null) } - }, [selectedPDLDetails]) + }, [selectedPDLDetails, pdls.length, selectedPDL]) // Track loading completion useEffect(() => { @@ -442,7 +549,7 @@ export default function Production() { }} >
- +

Statistiques de production

@@ -488,7 +595,7 @@ export default function Production() { }} >
- +

Graphiques de production

@@ -535,7 +642,7 @@ export default function Production() { }} >
- +

Courbe de production détaillée

@@ -584,7 +691,7 @@ export default function Production() { onClick={() => setIsInfoSectionExpanded(!isInfoSectionExpanded)} >
- +

Informations importantes

diff --git a/apps/web/src/pages/Simulator.tsx b/apps/web/src/pages/Simulator.tsx index 979a5b7..36f5062 100644 --- a/apps/web/src/pages/Simulator.tsx +++ b/apps/web/src/pages/Simulator.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { Calculator, AlertCircle, Loader2, ChevronDown, ChevronUp, FileDown, ArrowUpDown, ArrowUp, ArrowDown, Filter, Info, ArrowRight } from 'lucide-react' import { useQuery, useQueryClient, useIsRestoring } from '@tanstack/react-query' import { LoadingOverlay } from '@/components/LoadingOverlay' @@ -13,6 +13,8 @@ import { logger } from '@/utils/logger' import { ModernButton } from './Simulator/components/ModernButton' import { useIsDemo } from '@/hooks/useIsDemo' import { usePdlStore } from '@/stores/pdlStore' +import { useDataFetchStore } from '@/stores/dataFetchStore' +import { useUnifiedDataFetch } from '@/hooks/useUnifiedDataFetch' // Helper function to check if a date is weekend (Saturday or Sunday) function isWeekend(dateString: string): boolean { @@ -125,8 +127,10 @@ export default function Simulator() { const queryClient = useQueryClient() const isRestoring = useIsRestoring() const isDemo = useIsDemo() + const { setIsLoading } = useDataFetchStore() + const demoAutoFetchDone = useRef(false) - // Fetch user's PDLs + // Fetch user's PDLs - with short staleTime to ensure provider changes are reflected const { data: pdlsData, isLoading: pdlsLoading, error: pdlsError } = useQuery({ queryKey: ['pdls'], queryFn: async () => { @@ -139,6 +143,7 @@ export default function Simulator() { logger.warn('[Simulator] Returning empty array, response was:', response) return [] }, + staleTime: 30 * 1000, // 30 seconds - same as Dashboard for consistency }) // Fetch energy providers and offers @@ -255,6 +260,25 @@ export default function Simulator() { const [sortBy, setSortBy] = useState<'total' | 'subscription' | 'energy'>('total') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') + // Get PDL details for demo auto-fetch + const allPDLs: PDL[] = Array.isArray(pdlsData) ? pdlsData : [] + const selectedPDLDetails = allPDLs.find(p => p.usage_point_id === selectedPdl) + + // Hook for demo auto-fetch + const { fetchAllData } = useUnifiedDataFetch({ + selectedPDL: selectedPdl, + selectedPDLDetails, + allPDLs, + pageContext: 'consumption', + }) + + // Check if data is in cache for auto-fetch logic + const hasDataInCache = useMemo(() => { + if (!selectedPdl) return false + const cachedData = queryClient.getQueryData(['consumptionDetail', selectedPdl]) as any + return cachedData?.data?.meter_reading?.interval_reading?.length > 0 + }, [selectedPdl, queryClient, cachedConsumptionData]) + // Set first active PDL as selected by default useEffect(() => { logger.log('[Simulator] pdlsData in useEffect:', pdlsData, 'isArray:', Array.isArray(pdlsData)) @@ -291,6 +315,22 @@ export default function Simulator() { } }, [selectedPdl, offersLoading, providersLoading, isConsumptionCacheLoading]) + // Auto-fetch data for demo account + useEffect(() => { + if (isDemo && selectedPdl && selectedPDLDetails && !demoAutoFetchDone.current && !hasDataInCache && !isInitializing) { + logger.log('[DEMO] Auto-fetching data for demo account on Simulator') + demoAutoFetchDone.current = true + setTimeout(async () => { + setIsLoading(true) + try { + await fetchAllData() + } finally { + setIsLoading(false) + } + }, 300) + } + }, [isDemo, selectedPdl, selectedPDLDetails, hasDataInCache, isInitializing, setIsLoading, fetchAllData]) + // Auto-collapse info section when simulation results are available useEffect(() => { if (simulationResult && Array.isArray(simulationResult) && simulationResult.length > 0) { diff --git a/docs/pages/consumption.md b/docs/pages/consumption.md index 5e92593..423e803 100644 --- a/docs/pages/consumption.md +++ b/docs/pages/consumption.md @@ -202,6 +202,78 @@ Page permettant aux utilisateurs de **visualiser et analyser leur consommation - lucide-react pour les icônes - react-hot-toast pour les notifications +## Design visuel + +### Couleurs des sections + +Chaque section utilise une icône colorée distinctive : + +| Section | Icône | Couleur | +|---------|-------|---------| +| Statistiques de consommation | ⚡ Zap | Ambre (`amber-500`) | +| Graphiques de consommation | 📊 BarChart3 | Émeraude (`emerald-500`) | +| Courbe de charge détaillée | 📈 LineChart | Indigo (`indigo-500`) | +| Pics de puissance maximale | 📉 TrendingUp | Rouge (`red-500`) | + +### Cartes statistiques annuelles + +Les cartes `YearlyStatCards` utilisent des gradients colorés rotatifs : + +1. **Bleu → Indigo** : `from-blue-50 to-indigo-100` +2. **Émeraude → Teal** : `from-emerald-50 to-teal-100` +3. **Violet → Violet** : `from-purple-50 to-violet-100` +4. **Ambre → Orange** : `from-amber-50 to-orange-100` + +Chaque carte affiche : +- Icône Zap colorée +- Année et période (dates) +- Consommation en kWh +- Comparaison année précédente (tendance haut/bas) + +### Sélecteurs d'années (tabs) + +Les sélecteurs d'années utilisent les mêmes couleurs que les graphiques associés : + +**Style actif :** +```css +background-color: rgba(couleur, 0.125); +border-color: couleur; +color: couleur; +``` + +**Style inactif :** +```css +text-gray-400 border-gray-700 +hover:text-gray-200 hover:border-gray-600 +``` + +**Couleurs par composant :** + +| Composant | Couleurs des tabs | +|-----------|-------------------| +| AnnualCurve | `#3B82F6` (bleu), `#10B981` (émeraude), `#F59E0B` (ambre), `#8B5CF6` (violet) | +| PowerPeaks | `#EF4444` (rouge), `#F59E0B` (ambre), `#10B981` (émeraude), `#8B5CF6` (violet) | +| MonthlyHcHp | Nuances de bleu (`#3B82F6`, `#93C5FD`, `#60A5FA`, `#2563EB`) | +| HcHpDistribution | Nuances de bleu (`#60A5FA`, `#3B82F6`, `#93C5FD`, `#2563EB`) | + +### Camembert HC/HP + +Le camembert de répartition HC/HP utilise des couleurs semi-transparentes : + +- **Heures Creuses (HC)** : `rgba(96, 165, 250, 0.6)` (bleu-400 avec 60% opacité) +- **Heures Pleines (HP)** : `rgba(251, 146, 60, 0.6)` (orange-400 avec 60% opacité) + +### Graphiques avec gradients + +Les conteneurs de graphiques utilisent des gradients subtils : + +| Composant | Gradient | +|-----------|----------| +| YearlyConsumption | `from-sky-50 to-blue-100` | +| AnnualCurve | `from-teal-50 to-emerald-100` | +| MonthlyHcHp | `from-indigo-50 to-cyan-100` | +| PowerPeaks | `from-red-50 to-orange-100` | + ## Fichiers lies - **Frontend** : `apps/web/src/pages/ConsumptionKwh/` (dossier avec composants) @@ -259,8 +331,7 @@ Page permettant aux utilisateurs de **visualiser et analyser leur consommation apps/web/src/pages/ConsumptionKwh/ ├── index.tsx # Page principale ├── components/ -│ ├── AnnualCurve.tsx # Courbe annuelle -│ ├── ConfirmModal.tsx # Modal de confirmation +│ ├── AnnualCurve.tsx # Courbe annuelle avec sélecteurs colorés │ ├── DataFetchSection.tsx # Section recuperation donnees │ ├── HcHpDistribution.tsx # Repartition HC/HP (camemberts) │ ├── InfoBlock.tsx # Bloc d'information @@ -268,9 +339,9 @@ apps/web/src/pages/ConsumptionKwh/ │ ├── ModernButton.tsx # Bouton moderne │ ├── MonthlyHcHp.tsx # HC/HP mensuel (barres) │ ├── PDLSelector.tsx # Selecteur PDL -│ ├── PowerPeaks.tsx # Pics de puissance -│ ├── YearlyConsumption.tsx # Comparaison mensuelle -│ └── YearlyStatCards.tsx # Cartes annuelles +│ ├── PowerPeaks.tsx # Pics de puissance avec sélecteurs colorés +│ ├── YearlyConsumption.tsx # Comparaison mensuelle avec gradient +│ └── YearlyStatCards.tsx # Cartes annuelles avec gradients colorés ├── hooks/ │ ├── useConsumptionCalcs.ts # Calculs consommation │ ├── useConsumptionData.ts # Gestion donnees diff --git a/docs/pages/dashboard.md b/docs/pages/dashboard.md index 9fe2dbc..2175f5c 100644 --- a/docs/pages/dashboard.md +++ b/docs/pages/dashboard.md @@ -406,3 +406,107 @@ Cette fonctionnalité pose les bases pour : - Un PDL de production peut être lié à plusieurs PDL de consommation - Compatible SQLite et PostgreSQL - Aucune donnée n'est copiée, seul le lien (UUID) est stocké + +--- + +## 💰 Fonctionnalité : Sélection de l'offre tarifaire + +### Description + +Cette fonctionnalité permet aux utilisateurs de **sélectionner leur offre tarifaire actuelle** pour chaque PDL. L'offre sélectionnée est utilisée dans le simulateur pour comparer avec d'autres offres disponibles. + +### Interface utilisateur + +#### 1. Sélecteur d'offre dans PDLCard + +Le composant `OfferSelector` affiche 3 sélecteurs sur une seule ligne : + +| Sélecteur | Description | +|-----------|-------------| +| **Fournisseur** | Liste des fournisseurs d'énergie (EDF, Enercoop, TotalEnergies, etc.) | +| **Type** | Type d'offre (Base, Heures Creuses, Tempo, EJP, Weekend, Saisonnier) | +| **Offre** | Nom de l'offre spécifique | + +#### 2. Affichage des prix détaillés + +Une fois l'offre sélectionnée, un bloc récapitulatif s'affiche avec : + +- **En-tête** : Fournisseur - Nom de l'offre + badge du type +- **Abonnement** : Prix mensuel en €/mois +- **Puissance** : Si spécifiée dans l'offre (kVA) +- **Prix détaillés** : Tous les prix selon le type d'offre (en €/kWh) +- **Date de mise à jour** : Dernière actualisation des tarifs + +#### 3. Types d'offres et prix affichés + +| Type | Prix affichés | +|------|---------------| +| **BASE** | Prix kWh unique | +| **HC_HP** | Heures Pleines, Heures Creuses | +| **TEMPO** | Bleu HP/HC, Blanc HP/HC, Rouge HP/HC (6 prix) | +| **EJP** | Jours normaux, Jours de pointe | +| **WEEKEND** | Semaine HP/HC, Week-end HP/HC | +| **SEASONAL** | Hiver HP/HC, Été HP/HC, Jours de pointe | + +#### 4. Codes couleur des prix + +- 🔵 **Bleu** : Jours Tempo Bleus +- ⚪ **Gris** : Jours Tempo Blancs +- 🔴 **Rouge** : Jours Tempo Rouges / Jours de pointe +- 🟣 **Violet** : Week-end +- 🔷 **Cyan** : Hiver (offres saisonnières) +- 🟠 **Ambre** : Été (offres saisonnières) + +### API Backend + +**Endpoint :** + +```http +PATCH /api/pdl/{pdl_id}/offer +Content-Type: application/json + +{ + "selected_offer_id": "uuid-de-l-offre" | null +} +``` + +### Fichiers impactés + +**Frontend :** +- [apps/web/src/components/OfferSelector.tsx](../../apps/web/src/components/OfferSelector.tsx) : Composant de sélection +- [apps/web/src/components/PDLCard.tsx](../../apps/web/src/components/PDLCard.tsx) : Intégration du sélecteur +- [apps/web/src/api/energy.ts](../../apps/web/src/api/energy.ts) : Types EnergyOffer, EnergyProvider +- [apps/web/src/api/pdl.ts](../../apps/web/src/api/pdl.ts) : Méthode `updateSelectedOffer` + +**Backend :** +- [apps/api/src/models/pdl.py](../../apps/api/src/models/pdl.py) : Champ `selected_offer_id` +- [apps/api/src/routers/pdl.py](../../apps/api/src/routers/pdl.py) : Endpoint de mise à jour + +### Utilisation + +**Pour l'utilisateur :** + +1. Dans la carte PDL, section "Offre tarifaire" +2. Sélectionner le fournisseur +3. Sélectionner le type d'offre +4. Sélectionner l'offre spécifique +5. Le récapitulatif des prix s'affiche automatiquement +6. Cliquer sur ✕ pour effacer la sélection + +### Design + +- 3 sélecteurs alignés sur une ligne (`grid-cols-3`) +- Labels compacts avec icônes (Building2, Zap, Tag) +- Bloc récapitulatif en fond bleu clair +- Prix en €/kWh avec 4 décimales +- Abonnement en €/mois avec 2 décimales +- Support du mode sombre +- Responsive (s'adapte aux petits écrans) + +### Notes techniques + +- Les offres sont filtrées par puissance souscrite du PDL +- Seules les offres actives (`is_active = true`) sont affichées +- Les sélecteurs sont en cascade : Type dépend du Fournisseur, Offre dépend du Type +- La sélection est persistée immédiatement via mutation React Query +- Le cache des offres est conservé 5 minutes (`staleTime`)