diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index acdbd3e..1d60d87 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { LogOut, Store, Menu } from "lucide-react"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useIsMobile } from "@/hooks/use-mobile"; import Sidebar from "./Sidebar"; +import WeatherWidget from "./WeatherWidget"; import type { Group } from "@shared/schema"; interface StoreContextType { @@ -91,6 +92,8 @@ export default function Layout({ children }: LayoutProps) { )} + {/* Weather widget */} + {/* Store selector for admin - responsive */} diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx index d6484b7..d4f27a9 100644 --- a/client/src/components/RouterProduction.tsx +++ b/client/src/components/RouterProduction.tsx @@ -22,6 +22,7 @@ import Utilities from "@/pages/Utilities"; import Tasks from "@/pages/Tasks"; import TasksSimplified from "@/pages/TasksSimplified"; import SavTickets from "@/pages/SavTickets"; +import WeatherSettings from "@/pages/WeatherSettings"; import Layout from "@/components/Layout"; function RouterProduction() { @@ -101,6 +102,7 @@ function RouterProduction() { + diff --git a/client/src/components/WeatherWidget.tsx b/client/src/components/WeatherWidget.tsx new file mode 100644 index 0000000..8fb3d15 --- /dev/null +++ b/client/src/components/WeatherWidget.tsx @@ -0,0 +1,110 @@ +import { useQuery } from "@tanstack/react-query"; +import { Cloud, CloudRain, Sun, CloudSnow, ThermometerSun } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; + +interface WeatherData { + id: number; + date: string; + location: string; + maxTemperature: number; + minTemperature: number; + condition: string; + icon: string; + isCurrentYear: boolean; + createdAt: string; + updatedAt: string; +} + +interface WeatherResponse { + currentYear: WeatherData | null; + previousYear: WeatherData | null; + location: string; +} + +const WeatherIcon = ({ condition, size = 16 }: { condition: string; size?: number }) => { + const iconClass = `h-${Math.floor(size/4)} w-${Math.floor(size/4)}`; + + if (condition.includes('rain') || condition.includes('pluie')) { + return ; + } + if (condition.includes('snow') || condition.includes('neige')) { + return ; + } + if (condition.includes('cloud') || condition.includes('nuage')) { + return ; + } + if (condition.includes('sun') || condition.includes('soleil') || condition.includes('clear')) { + return ; + } + return ; +}; + +export default function WeatherWidget() { + const { data: weather, isLoading, error } = useQuery({ + queryKey: ['/api/weather/current'], + refetchInterval: 30 * 60 * 1000, // Refetch every 30 minutes + retry: 1, + }); + + if (isLoading) { + return ( + + +
+
+ +
+ Chargement météo... +
+
+
+ ); + } + + if (error || !weather) { + return null; // Don't show widget if weather service is not configured + } + + const currentTemp = weather.currentYear?.maxTemperature; + const previousTemp = weather.previousYear?.maxTemperature; + const location = weather.location || "Nancy"; + + return ( + + +
+
+ + + {location.split(',')[0]} + +
+ +
+
+ {currentTemp !== undefined ? `${Math.round(currentTemp)}°` : '--°'} +
+ + {previousTemp !== undefined && ( +
+ (l'an dernier: {Math.round(previousTemp)}°) +
+ )} +
+ + {currentTemp !== undefined && previousTemp !== undefined && ( +
+ {currentTemp > previousTemp ? ( + ↑{Math.round(currentTemp - previousTemp)}° + ) : currentTemp < previousTemp ? ( + ↓{Math.round(previousTemp - currentTemp)}° + ) : ( + = + )} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/pages/DlcPage.tsx b/client/src/pages/DlcPage.tsx index d0f2707..808436a 100644 --- a/client/src/pages/DlcPage.tsx +++ b/client/src/pages/DlcPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -44,58 +44,53 @@ export default function DlcPage() { const [statusFilter, setStatusFilter] = useState("all"); const [supplierFilter, setSupplierFilter] = useState("all"); const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingProduct, setEditingProduct] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [productToDelete, setProductToDelete] = useState(null); - // Fetch stores/groups + // Debounce search term to reduce API calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm]); + + // Fetch stores/groups - optimized cache const { data: stores = [] } = useQuery({ queryKey: ["/api/groups"], enabled: !authLoading, + staleTime: 5 * 60 * 1000, // 5 minutes cache }); - - - // Fetch suppliers with DLC enabled only + // Fetch suppliers with DLC enabled only - optimized cache const { data: suppliers = [] } = useQuery({ queryKey: ["/api/suppliers", "dlc"], queryFn: () => apiRequest("/api/suppliers?dlc=true"), enabled: !authLoading, + staleTime: 10 * 60 * 1000, // 10 minutes cache }); - // Fetch DLC products + // Fetch DLC products - optimized with server-side search const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({ - queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter], + queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter, debouncedSearchTerm], queryFn: () => { const params = new URLSearchParams(); if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString()); if (statusFilter && statusFilter !== "all") params.append("status", statusFilter); if (supplierFilter && supplierFilter !== "all") params.append("supplierId", supplierFilter); - - if (import.meta.env.DEV) { - console.log("🔍 Requête DLC products:", { - url: `/api/dlc-products?${params.toString()}`, - user: user?.role, - selectedStoreId, - userGroups: user?.userGroups?.map(ug => ({ groupId: ug.groupId, groupName: ug.group?.name })) - }); - } + if (debouncedSearchTerm) params.append("search", debouncedSearchTerm); return apiRequest(`/api/dlc-products?${params.toString()}`); }, enabled: !authLoading, - onSuccess: (data) => { - if (import.meta.env.DEV) { - console.log("📋 DLC products reçus:", { - total: data.length, - sample: data.slice(0, 3).map(d => ({ id: d.id, productName: d.productName, groupId: d.groupId })) - }); - } - } + staleTime: 2 * 60 * 1000, // 2 minutes cache for DLC data }); - // Fetch DLC stats + // Fetch DLC stats - optimized cache const { data: stats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({ queryKey: ["/api/dlc-products/stats", selectedStoreId], queryFn: () => { @@ -104,6 +99,7 @@ export default function DlcPage() { return apiRequest(`/api/dlc-products/stats?${params.toString()}`); }, enabled: !authLoading, + staleTime: 5 * 60 * 1000, // 5 minutes cache for stats }); // Form setup @@ -118,16 +114,13 @@ export default function DlcPage() { }, }); - // Create mutation + // Create mutation - optimized cache invalidation const createMutation = useMutation({ mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data), onSuccess: () => { - // Invalidate all DLC queries with any combination of parameters + // Selective cache invalidation queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false }); queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false }); - if (import.meta.env.DEV) { - console.log("🔄 Cache DLC invalidé après création"); - } toast({ title: "Produit DLC créé avec succès" }); setIsDialogOpen(false); form.reset(); @@ -142,16 +135,13 @@ export default function DlcPage() { }, }); - // Update mutation + // Update mutation - optimized cache invalidation const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => apiRequest(`/api/dlc-products/${id}`, "PUT", data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false }); queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false }); - if (import.meta.env.DEV) { - console.log("🔄 Cache DLC invalidé après mise à jour"); - } toast({ title: "Produit DLC mis à jour avec succès" }); setIsDialogOpen(false); form.reset(); @@ -166,15 +156,12 @@ export default function DlcPage() { }, }); - // Validate mutation + // Validate mutation - optimized cache invalidation const validateMutation = useMutation({ mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "PUT"), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false }); queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false }); - if (import.meta.env.DEV) { - console.log("🔄 Cache DLC invalidé après validation"); - } toast({ title: "Produit validé avec succès" }); }, onError: (error: any) => { @@ -186,13 +173,12 @@ export default function DlcPage() { }, }); - // Delete mutation + // Delete mutation - optimized cache invalidation const deleteMutation = useMutation({ mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"], exact: false }); queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"], exact: false }); - console.log("🔄 Cache DLC invalidé après suppression"); toast({ title: "Produit supprimé avec succès" }); }, onError: (error: any) => { @@ -325,13 +311,7 @@ export default function DlcPage() { }; const printExpiringSoon = () => { - const expiringSoon = filteredProducts.filter(product => { - const today = new Date(); - const expiry = new Date(product.dlcDate || new Date()); - const diffTime = expiry.getTime() - today.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - return diffDays > 0 && diffDays <= 15; - }); + const expiringSoon = expiringSoonProducts; const printContent = ` @@ -390,11 +370,7 @@ export default function DlcPage() { }; const printExpired = () => { - const expired = filteredProducts.filter(product => { - const today = new Date(); - const expiry = new Date(product.dlcDate || new Date()); - return expiry < today; - }); + const expired = expiredProducts; const printContent = ` @@ -452,13 +428,27 @@ export default function DlcPage() { } }; - const filteredProducts = (dlcProducts || []).filter(product => { - if (searchTerm) { - return product.productName.toLowerCase().includes(searchTerm.toLowerCase()) || - (product.supplier?.name && product.supplier.name.toLowerCase().includes(searchTerm.toLowerCase())); - } - return true; - }); + // Products are already filtered server-side, no client filtering needed + const filteredProducts = dlcProducts || []; + + // Memoized calculations for print functions + const { expiringSoonProducts, expiredProducts } = useMemo(() => { + const today = new Date(); + const expiringSoon = filteredProducts.filter(product => { + if (product.status === 'valides') return false; + const expiryDate = new Date(product.dlcDate || new Date()); + const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + return diffDays <= 15 && diffDays > 0; + }); + + const expired = filteredProducts.filter(product => { + if (product.status === 'valides') return false; + const expiryDate = new Date(product.dlcDate || new Date()); + return expiryDate <= today; + }); + + return { expiringSoonProducts: expiringSoon, expiredProducts: expired }; + }, [filteredProducts]); // Pagination const { diff --git a/client/src/pages/Utilities.tsx b/client/src/pages/Utilities.tsx index 3b9cc19..f46a5c6 100644 --- a/client/src/pages/Utilities.tsx +++ b/client/src/pages/Utilities.tsx @@ -5,7 +5,8 @@ import { Settings, Database, Server, - Bug + Bug, + Cloud } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -15,6 +16,7 @@ import { AlertTriangle } from "lucide-react"; import BackupManager from "./BackupManager"; import NocoDBConfig from "./NocoDBConfig"; import DatabaseDebug from "./DatabaseDebug"; +import WeatherSettings from "./WeatherSettings"; export default function Utilities() { const { user } = useAuthUnified(); @@ -24,6 +26,7 @@ export default function Utilities() { if (path === '/backup') return 'backups'; if (path === '/nocodb-config') return 'nocodb'; if (path === '/database-debug') return 'debug'; + if (path === '/weather-settings') return 'weather'; return 'backups'; }); @@ -31,9 +34,10 @@ export default function Utilities() { const canManageBackups = hasPermission(user?.role || '', 'backups', 'manage'); const canManageNocoDB = user?.role === 'admin'; const canDebugDatabase = user?.role === 'admin'; + const canManageWeather = user?.role === 'admin'; // Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé - if (!canManageBackups && !canManageNocoDB && !canDebugDatabase) { + if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather) { return (
@@ -69,7 +73,7 @@ export default function Utilities() { {/* Content avec onglets */}
- + {canManageBackups && ( @@ -88,6 +92,12 @@ export default function Utilities() { Debug Base de Données )} + {canManageWeather && ( + + + Météo + + )} {/* Contenu des onglets */} @@ -114,6 +124,14 @@ export default function Utilities() {
)} + + {canManageWeather && ( + +
+ +
+
+ )}
diff --git a/client/src/pages/WeatherSettings.tsx b/client/src/pages/WeatherSettings.tsx new file mode 100644 index 0000000..290c0aa --- /dev/null +++ b/client/src/pages/WeatherSettings.tsx @@ -0,0 +1,265 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { AlertCircle, CheckCircle, Cloud, Settings } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; + +interface WeatherSettings { + id: number; + apiKey: string; + location: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export default function WeatherSettings() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isTesting, setIsTesting] = useState(false); + + // Fetch weather settings + const { data: settings, isLoading } = useQuery({ + queryKey: ['/api/weather/settings'], + }); + + // Update settings mutation + const updateSettingsMutation = useMutation({ + mutationFn: async (data: Partial) => { + const url = settings?.id ? `/api/weather/settings/${settings.id}` : '/api/weather/settings'; + const method = settings?.id ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('Failed to save settings'); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/weather/settings'] }); + toast({ + title: "Configuration sauvegardée", + description: "Les paramètres météo ont été mis à jour avec succès.", + }); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de sauvegarder les paramètres.", + variant: "destructive", + }); + }, + }); + + // Test API connection + const testConnection = async (apiKey: string, location: string) => { + setIsTesting(true); + setTestResult(null); + + try { + const response = await fetch('/api/weather/test-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ apiKey, location }), + }); + + if (!response.ok) { + throw new Error('Test connection failed'); + } + + const result = await response.json(); + setTestResult(result); + } catch (error: any) { + setTestResult({ + success: false, + message: error.message || "Erreur lors du test de connexion" + }); + } finally { + setIsTesting(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const data = { + apiKey: formData.get('apiKey') as string, + location: formData.get('location') as string, + isActive: formData.get('isActive') === 'on', + }; + + updateSettingsMutation.mutate(data); + }; + + if (isLoading) { + return ( +
+
+ +

Configuration Météo

+
+ + +
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+
+ +

Configuration Météo

+
+ + + + + + Paramètres API Visual Crossing + + + Configurez l'API Visual Crossing pour afficher la météo du jour actuel et de l'année précédente. + + + +
+
+ + +

+ Obtenez votre clé API gratuite sur{" "} + + visualcrossing.com + +

+
+ +
+ + +

+ Ville ou région pour laquelle afficher la météo +

+
+ +
+ + +
+ + {testResult && ( + + {testResult.success ? ( + + ) : ( + + )} + {testResult.message} + + )} + +
+ + + +
+
+
+
+ + + + À propos + + +

+ Le widget météo affiche la température maximale du jour actuel ainsi que + celle du même jour l'année précédente, permettant une comparaison rapide. +

+

+ Les données sont mises en cache pour éviter les appels API répétés et + optimiser les performances. +

+

+ L'affichage météo apparaît en haut à gauche de l'interface, + à côté du sélecteur de magasin. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index ba827d7..6f1e892 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -36,12 +36,15 @@ import { insertTaskSchema, insertNocodbConfigSchema, insertSavTicketSchema, - insertSavTicketHistorySchema + insertSavTicketHistorySchema, + insertWeatherDataSchema, + insertWeatherSettingsSchema } from "@shared/schema"; import { hasPermission } from "@shared/permissions"; import { z } from "zod"; import { invoiceVerificationService } from "./invoiceVerification"; import { backupService } from "./backupService"; +import { weatherService } from "./weatherService.js"; export async function registerRoutes(app: Express): Promise { // Health check endpoint for Docker @@ -986,7 +989,7 @@ export async function registerRoutes(app: Express): Promise { return res.status(404).json({ message: "User not found" }); } - const { storeId, status, supplierId } = req.query; + const { storeId, status, supplierId, search } = req.query; let groupIds: number[] | undefined; if (user.role === 'admin') { @@ -1003,10 +1006,9 @@ export async function registerRoutes(app: Express): Promise { const filters: any = {}; if (status && status !== 'all') filters.status = status as string; if (supplierId && supplierId !== 'all') filters.supplierId = parseInt(supplierId as string); + if (search) filters.search = search as string; - console.log('DLC Products API called with:', { groupIds, filters, userRole: user.role }); const dlcProducts = await storage.getDlcProducts(groupIds, filters); - console.log('DLC Products returned:', dlcProducts.length, 'items'); res.json(dlcProducts); } catch (error) { console.error("Error fetching DLC products:", error); @@ -2754,6 +2756,118 @@ RÉSUMÉ DU SCAN } }); + // Weather routes + app.get('/api/weather/settings', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const settings = await storage.getWeatherSettings(); + res.json(settings); + } catch (error) { + console.error("Error fetching weather settings:", error); + res.status(500).json({ message: "Failed to fetch weather settings" }); + } + }); + + app.post('/api/weather/settings', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const data = insertWeatherSettingsSchema.parse(req.body); + const settings = await storage.createWeatherSettings(data); + res.json(settings); + } catch (error) { + console.error("Error creating weather settings:", error); + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Validation error", errors: error.errors }); + } + res.status(500).json({ message: "Failed to create weather settings" }); + } + }); + + app.put('/api/weather/settings/:id', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const id = parseInt(req.params.id); + const data = insertWeatherSettingsSchema.partial().parse(req.body); + const settings = await storage.updateWeatherSettings(id, data); + res.json(settings); + } catch (error) { + console.error("Error updating weather settings:", error); + if (error instanceof z.ZodError) { + return res.status(400).json({ message: "Validation error", errors: error.errors }); + } + res.status(500).json({ message: "Failed to update weather settings" }); + } + }); + + app.post('/api/weather/test-connection', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const { apiKey, location } = req.body; + + if (!apiKey || !location) { + return res.status(400).json({ message: "API key and location are required" }); + } + + const result = await weatherService.testApiConnection(apiKey, location); + res.json(result); + } catch (error) { + console.error("Error testing weather API connection:", error); + res.status(500).json({ message: "Failed to test API connection" }); + } + }); + + app.get('/api/weather/current', isAuthenticated, async (req: any, res) => { + try { + const settings = await storage.getWeatherSettings(); + + if (!settings || !settings.isActive) { + return res.status(404).json({ message: "Weather service not configured or disabled" }); + } + + const today = new Date().toISOString().split('T')[0]; + const previousYearDate = weatherService.getPreviousYearDate(); + + // Check if we already have today's data + let currentYearData = await storage.getWeatherData(today, true); + let previousYearData = await storage.getWeatherData(previousYearDate, false); + + // Fetch current year data if not in cache + if (!currentYearData) { + console.log("🌤️ Fetching current weather data from API"); + const apiData = await weatherService.fetchCurrentWeather(settings); + if (apiData) { + const weatherData = weatherService.convertApiDataToWeatherData(apiData, settings.location, true); + if (weatherData) { + currentYearData = await storage.createWeatherData(weatherData); + } + } + } + + // Fetch previous year data if not in cache + if (!previousYearData) { + console.log("🌤️ Fetching previous year weather data from API"); + const apiData = await weatherService.fetchPreviousYearWeather(settings, previousYearDate); + if (apiData) { + const weatherData = weatherService.convertApiDataToWeatherData(apiData, settings.location, false); + if (weatherData) { + previousYearData = await storage.createWeatherData(weatherData); + } + } + } + + res.json({ + currentYear: currentYearData ? { + ...currentYearData, + icon: weatherService.getWeatherIcon(currentYearData.icon) + } : null, + previousYear: previousYearData ? { + ...previousYearData, + icon: weatherService.getWeatherIcon(previousYearData.icon) + } : null, + location: settings.location + }); + } catch (error) { + console.error("Error fetching weather data:", error); + res.status(500).json({ message: "Failed to fetch weather data" }); + } + }); + // Create server instance const httpServer = createServer(app); diff --git a/server/storage.ts b/server/storage.ts index 7eaa570..ddc126e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -14,6 +14,8 @@ import { invoiceVerificationCache, savTickets, savTicketHistory, + weatherData, + weatherSettings, type User, type UpsertUser, type Group, @@ -55,6 +57,10 @@ import { type InsertSavTicketHistory, type SavTicketWithRelations, type SavTicketHistoryWithCreator, + type WeatherData, + type InsertWeatherData, + type WeatherSettings, + type InsertWeatherSettings, } from "@shared/schema"; import { db } from "./db"; import { eq, and, inArray, desc, sql, gte, lte, lt, or, isNull, isNotNull, asc } from "drizzle-orm"; @@ -149,7 +155,7 @@ export interface IStorage { deleteCustomerOrder(id: number): Promise; // DLC Product operations - getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; }): Promise; + getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise; getDlcProduct(id: number): Promise; createDlcProduct(dlcProduct: InsertDlcProduct): Promise; updateDlcProduct(id: number, dlcProduct: Partial): Promise; @@ -187,6 +193,15 @@ export interface IStorage { resolvedTickets: number; criticalTickets: number; }>; + + // Weather operations + getWeatherSettings(): Promise; + createWeatherSettings(settings: InsertWeatherSettings): Promise; + updateWeatherSettings(id: number, settings: Partial): Promise; + getWeatherData(date: string, isCurrentYear: boolean): Promise; + createWeatherData(data: InsertWeatherData): Promise; + updateWeatherData(id: number, data: Partial): Promise; + deleteOldWeatherData(daysToKeep: number): Promise; } export class DatabaseStorage implements IStorage { @@ -1350,7 +1365,7 @@ export class DatabaseStorage implements IStorage { await db.delete(customerOrders).where(eq(customerOrders.id, id)); } - async getDlcProducts(groupIds?: number[]): Promise { + async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let query = db .select({ dlcProduct: dlcProducts, @@ -1361,8 +1376,31 @@ export class DatabaseStorage implements IStorage { .leftJoin(suppliers, eq(dlcProducts.supplierId, suppliers.id)) .leftJoin(groups, eq(dlcProducts.groupId, groups.id)); + const conditions = []; + if (groupIds && groupIds.length > 0) { - query = query.where(inArray(dlcProducts.groupId, groupIds)); + conditions.push(inArray(dlcProducts.groupId, groupIds)); + } + + if (filters) { + if (filters.status) { + conditions.push(eq(dlcProducts.status, filters.status)); + } + if (filters.supplierId) { + conditions.push(eq(dlcProducts.supplierId, filters.supplierId)); + } + if (filters.search) { + conditions.push( + or( + sql`LOWER(${dlcProducts.productName}) LIKE LOWER(${'%' + filters.search + '%'})`, + sql`LOWER(${suppliers.name}) LIKE LOWER(${'%' + filters.search + '%'})` + ) + ); + } + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); } const results = await query.orderBy(asc(dlcProducts.expiryDate)); @@ -1725,6 +1763,63 @@ export class DatabaseStorage implements IStorage { return stats; } + + // Weather operations + async getWeatherSettings(): Promise { + const [settings] = await db.select().from(weatherSettings).limit(1); + return settings; + } + + async createWeatherSettings(settingsData: InsertWeatherSettings): Promise { + const [settings] = await db.insert(weatherSettings).values(settingsData).returning(); + return settings; + } + + async updateWeatherSettings(id: number, settingsData: Partial): Promise { + const [settings] = await db + .update(weatherSettings) + .set({ ...settingsData, updatedAt: new Date() }) + .where(eq(weatherSettings.id, id)) + .returning(); + return settings; + } + + async getWeatherData(date: string, isCurrentYear: boolean): Promise { + const [data] = await db + .select() + .from(weatherData) + .where(and( + eq(weatherData.date, date), + eq(weatherData.isCurrentYear, isCurrentYear) + )) + .limit(1); + return data; + } + + async createWeatherData(data: InsertWeatherData): Promise { + const [weatherInfo] = await db.insert(weatherData).values(data).returning(); + return weatherInfo; + } + + async updateWeatherData(id: number, data: Partial): Promise { + const [weatherInfo] = await db + .update(weatherData) + .set({ ...data, updatedAt: new Date() }) + .where(eq(weatherData.id, id)) + .returning(); + return weatherInfo; + } + + async deleteOldWeatherData(daysToKeep: number): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + await db + .delete(weatherData) + .where(lt(weatherData.createdAt, cutoffDate)); + + console.log(`🧹 Deleted weather data older than ${daysToKeep} days`); + } } // MemStorage class for development @@ -2409,7 +2504,7 @@ export class MemStorage implements IStorage { this.customerOrders.delete(id); } - async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; }): Promise { + async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let products = Array.from(this.dlcProducts.values()); if (groupIds && groupIds.length > 0) { @@ -2423,6 +2518,14 @@ export class MemStorage implements IStorage { if (filters.supplierId) { products = products.filter(product => product.supplierId === filters.supplierId); } + if (filters.search) { + const searchTerm = filters.search.toLowerCase(); + products = products.filter(product => { + const supplier = this.suppliers.get(product.supplierId); + return product.productName.toLowerCase().includes(searchTerm) || + (supplier && supplier.name.toLowerCase().includes(searchTerm)); + }); + } } return products.map(product => ({ @@ -3078,6 +3181,66 @@ export class MemStorage implements IStorage { this.idCounters.savTicket = 6; console.log('✅ SAV test data initialized with 5 tickets and comprehensive history'); } + + // Weather operations + async getWeatherSettings(): Promise { + // In memory storage, return a default setting for development + return { + id: 1, + apiKey: "6PSHYLBJB9EEYNZ7NDQ5BPGBZ", // Token fourni par l'utilisateur + location: "Nancy, France", + isActive: true, + createdAt: new Date(), + updatedAt: new Date() + }; + } + + async createWeatherSettings(settings: InsertWeatherSettings): Promise { + // For development, we'll just return the created settings with an ID + return { + id: 1, + ...settings, + createdAt: new Date(), + updatedAt: new Date() + }; + } + + async updateWeatherSettings(id: number, settings: Partial): Promise { + const existing = await this.getWeatherSettings(); + return { + ...existing!, + ...settings, + updatedAt: new Date() + }; + } + + async getWeatherData(date: string, isCurrentYear: boolean): Promise { + // In development, return some mock data for testing + return undefined; // Will trigger API calls + } + + async createWeatherData(data: InsertWeatherData): Promise { + return { + id: 1, + ...data, + createdAt: new Date(), + updatedAt: new Date() + }; + } + + async updateWeatherData(id: number, data: Partial): Promise { + const existing = await this.getWeatherData(data.date || "", data.isCurrentYear || true); + return { + ...existing!, + ...data, + updatedAt: new Date() + }; + } + + async deleteOldWeatherData(daysToKeep: number): Promise { + // In development, this is a no-op + console.log(`🧹 DEV: Would delete weather data older than ${daysToKeep} days`); + } } // Use MemStorage in development, DatabaseStorage in production diff --git a/server/weatherService.ts b/server/weatherService.ts new file mode 100644 index 0000000..bf79112 --- /dev/null +++ b/server/weatherService.ts @@ -0,0 +1,172 @@ +import { WeatherData, WeatherSettings } from "../shared/schema.js"; + +export interface WeatherApiResponse { + days: Array<{ + datetime: string; + tempmax: number; + tempmin: number; + icon: string; + conditions: string; + }>; +} + +export interface WeatherDisplayData { + currentYear: WeatherData | null; + previousYear: WeatherData | null; +} + +export class WeatherService { + private baseUrl = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline"; + + /** + * Récupère les données météo actuelles depuis l'API Visual Crossing + */ + async fetchCurrentWeather(settings: WeatherSettings): Promise { + try { + const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json`; + + const response = await fetch(url); + if (!response.ok) { + console.error(`Weather API error: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Erreur lors de la récupération de la météo actuelle:", error); + return null; + } + } + + /** + * Récupère les données météo de l'année précédente pour la même date + */ + async fetchPreviousYearWeather(settings: WeatherSettings, targetDate: string): Promise { + try { + const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json&forecastBasisDate=${targetDate}`; + + const response = await fetch(url); + if (!response.ok) { + console.error(`Weather API error for previous year: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Erreur lors de la récupération de la météo de l'année précédente:", error); + return null; + } + } + + /** + * Convertit les données de l'API en format WeatherData + */ + convertApiDataToWeatherData(apiData: WeatherApiResponse, location: string, isCurrentYear: boolean): WeatherData | null { + if (!apiData.days || apiData.days.length === 0) { + return null; + } + + const todayData = apiData.days[0]; + + return { + id: 0, // Sera généré par la base de données + date: todayData.datetime, + location, + tempMax: todayData.tempmax.toString(), + tempMin: todayData.tempmin.toString(), + icon: todayData.icon, + conditions: todayData.conditions, + isCurrentYear, + createdAt: new Date(), + updatedAt: new Date() + }; + } + + /** + * Calcule la date de l'année précédente pour la même date + */ + getPreviousYearDate(date: Date = new Date()): string { + const previousYear = new Date(date); + previousYear.setFullYear(date.getFullYear() - 1); + return previousYear.toISOString().split('T')[0]; // Format YYYY-MM-DD + } + + /** + * Convertit l'icône météo en format d'affichage + */ + getWeatherIcon(iconCode: string): string { + const iconMap: { [key: string]: string } = { + 'clear-day': '☀️', + 'clear-night': '🌙', + 'partly-cloudy-day': '⛅', + 'partly-cloudy-night': '☁️', + 'cloudy': '☁️', + 'rain': '🌧️', + 'snow': '❄️', + 'wind': '💨', + 'fog': '🌫️', + 'thunder': '⛈️', + 'hail': '🌨️' + }; + + return iconMap[iconCode] || '🌤️'; + } + + /** + * Valide les paramètres de configuration météo + */ + validateWeatherSettings(settings: Partial): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!settings.apiKey || settings.apiKey.trim().length === 0) { + errors.push("La clé API Visual Crossing est requise"); + } + + if (!settings.location || settings.location.trim().length === 0) { + errors.push("La localisation est requise"); + } + + // Vérification basique du format de la clé API Visual Crossing + if (settings.apiKey && settings.apiKey.length < 10) { + errors.push("La clé API semble invalide (trop courte)"); + } + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * Test de connexion à l'API météo + */ + async testApiConnection(apiKey: string, location: string): Promise<{ success: boolean; message: string }> { + try { + const testUrl = `${this.baseUrl}/${encodeURIComponent(location)}?unitGroup=metric&include=days&key=${apiKey}&contentType=json`; + + const response = await fetch(testUrl); + + if (response.ok) { + const data = await response.json(); + if (data.days && data.days.length > 0) { + return { success: true, message: "Connexion réussie à l'API météo" }; + } else { + return { success: false, message: "Aucune donnée météo trouvée pour cette localisation" }; + } + } else if (response.status === 401) { + return { success: false, message: "Clé API invalide" }; + } else if (response.status === 404) { + return { success: false, message: "Localisation introuvable" }; + } else { + return { success: false, message: `Erreur API: ${response.status} ${response.statusText}` }; + } + } catch (error) { + console.error("Erreur test API météo:", error); + return { success: false, message: "Erreur de connexion à l'API météo" }; + } + } +} + +export const weatherService = new WeatherService(); \ No newline at end of file diff --git a/shared/schema.ts b/shared/schema.ts index 794ce0b..d7e31d5 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -700,3 +700,45 @@ export type SavTicketWithRelations = SavTicket & { export type SavTicketHistoryWithCreator = SavTicketHistory & { creator: User; }; + +// Weather Tables +export const weatherData = pgTable("weather_data", { + id: serial("id").primaryKey(), + date: date("date").notNull(), + location: text("location").notNull(), + tempMax: text("temp_max").notNull(), + tempMin: text("temp_min").notNull(), + icon: text("icon").notNull(), + conditions: text("conditions").notNull(), + isCurrentYear: boolean("is_current_year").notNull().default(true), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +export const weatherSettings = pgTable("weather_settings", { + id: serial("id").primaryKey(), + apiKey: text("api_key").notNull(), + location: text("location").notNull().default("Paris, France"), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +// Weather Schemas +export const insertWeatherDataSchema = createInsertSchema(weatherData).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export const insertWeatherSettingsSchema = createInsertSchema(weatherSettings).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +// Weather Types +export type WeatherData = typeof weatherData.$inferSelect; +export type InsertWeatherData = z.infer; +export type WeatherSettings = typeof weatherSettings.$inferSelect; +export type InsertWeatherSettings = z.infer;