mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-17 20:28:31 +02:00
feat: Add initial database migration and UI label component, alongside frontend dependency installation.
This commit is contained in:
1 parent
5884aa2378
commit
8d124bf0f8
17 files changed
+859
-40
No files matched your search
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock, Tag, History } from 'lucide-react';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Marquee } from '@/components/ui/marquee';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
||||
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom, onCategoryUpdate, categories, onViewHistory }) {
|
||||
const { t } = useTranslation();
|
||||
const [showCategorySelect, setShowCategorySelect] = useState(false);
|
||||
|
||||
const getStockStatus = (inStock) => {
|
||||
if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' };
|
||||
@@ -134,6 +136,34 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<div className="flex items-center gap-2">
|
||||
{item.category ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
<Tag className="h-2.5 w-2.5" />
|
||||
{item.category}
|
||||
</span>
|
||||
) : null}
|
||||
{onCategoryUpdate && (
|
||||
<Select
|
||||
value={item.category || ''}
|
||||
onValueChange={(value) => onCategoryUpdate(item.id, value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[120px] text-[10px]">
|
||||
<Tag className="h-2.5 w-2.5 mr-1" />
|
||||
<SelectValue placeholder="Catégorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Aucune</SelectItem>
|
||||
{categories && categories.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__">+ Nouvelle...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
@@ -159,6 +189,11 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{onViewHistory && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onViewHistory(item)} title="Historique des prix">
|
||||
<History className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onEdit(item)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { X, TrendingDown, TrendingUp, Minus } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export function PriceHistoryModal({ open, onClose, item }) {
|
||||
const [priceHistory, setPriceHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && item) {
|
||||
loadPriceHistory();
|
||||
}
|
||||
}, [open, item]);
|
||||
|
||||
const loadPriceHistory = async () => {
|
||||
if (!item) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/items/${item.id}/price-history`);
|
||||
setPriceHistory(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading price history:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
// Calculate stats
|
||||
const prices = priceHistory.map(h => h.price);
|
||||
const currentPrice = prices[prices.length - 1];
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0;
|
||||
|
||||
// Calculate price change
|
||||
const priceChange = prices.length > 1 ? ((currentPrice - prices[0]) / prices[0] * 100) : 0;
|
||||
|
||||
// Simple chart rendering
|
||||
const chartHeight = 200;
|
||||
const chartWidth = 600;
|
||||
const padding = 40;
|
||||
|
||||
const renderChart = () => {
|
||||
if (prices.length === 0) return null;
|
||||
|
||||
const minY = minPrice * 0.95;
|
||||
const maxY = maxPrice * 1.05;
|
||||
const rangeY = maxY - minY;
|
||||
|
||||
const points = priceHistory.map((point, index) => {
|
||||
const x = padding + (index / (priceHistory.length - 1 || 1)) * (chartWidth - 2 * padding);
|
||||
const y = chartHeight - padding - ((point.price - minY) / rangeY) * (chartHeight - 2 * padding);
|
||||
return { x, y, price: point.price, date: point.timestamp };
|
||||
});
|
||||
|
||||
const pathData = points.map((point, index) =>
|
||||
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
|
||||
).join(' ');
|
||||
|
||||
return (
|
||||
<svg width="100%" height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="mt-4">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = padding + ratio * (chartHeight - 2 * padding);
|
||||
const price = maxY - ratio * rangeY;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={y}
|
||||
x2={chartWidth - padding}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
/>
|
||||
<text x={10} y={y + 4} fontSize="10" fill="currentColor" opacity="0.5">
|
||||
{price.toFixed(2)}€
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area under curve */}
|
||||
<path
|
||||
d={`${pathData} L ${points[points.length - 1].x} ${chartHeight - padding} L ${points[0].x} ${chartHeight - padding} Z`}
|
||||
fill="currentColor"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-primary"
|
||||
/>
|
||||
|
||||
{/* Points */}
|
||||
{points.map((point, index) => (
|
||||
<g key={index}>
|
||||
<circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4"
|
||||
fill="currentColor"
|
||||
className="text-primary"
|
||||
/>
|
||||
<title>{`${new Date(point.date).toLocaleDateString('fr-FR')}: ${point.price.toFixed(2)}€`}</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span>Historique des prix</span>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
<h3 className="font-semibold line-clamp-2">{item.name}</h3>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{new URL(item.url).hostname.replace('www.', '')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Prix actuel</div>
|
||||
<div className="text-lg font-bold">{currentPrice?.toFixed(2) || '--'}€</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Prix minimum</div>
|
||||
<div className="text-lg font-bold text-green-600">{minPrice?.toFixed(2) || '--'}€</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Prix maximum</div>
|
||||
<div className="text-lg font-bold text-red-600">{maxPrice?.toFixed(2) || '--'}€</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Variation</div>
|
||||
<div className={`text-lg font-bold flex items-center gap-1 ${priceChange > 0 ? 'text-red-600' : priceChange < 0 ? 'text-green-600' : ''}`}>
|
||||
{priceChange > 0 && <TrendingUp className="h-4 w-4" />}
|
||||
{priceChange < 0 && <TrendingDown className="h-4 w-4" />}
|
||||
{priceChange === 0 && <Minus className="h-4 w-4" />}
|
||||
{Math.abs(priceChange).toFixed(1)}%
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[200px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : priceHistory.length > 0 ? (
|
||||
<div>
|
||||
{renderChart()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun historique de prix disponible
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Table */}
|
||||
{priceHistory.length > 0 && (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-background border-b">
|
||||
<tr>
|
||||
<th className="text-left p-2">Date</th>
|
||||
<th className="text-right p-2">Prix</th>
|
||||
<th className="text-right p-2">Variation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{priceHistory.map((entry, index) => {
|
||||
const prevPrice = index > 0 ? priceHistory[index - 1].price : entry.price;
|
||||
const change = ((entry.price - prevPrice) / prevPrice * 100);
|
||||
return (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2">
|
||||
{new Date(entry.timestamp).toLocaleString('fr-FR')}
|
||||
</td>
|
||||
<td className="text-right p-2 font-medium">
|
||||
{entry.price.toFixed(2)}€
|
||||
</td>
|
||||
<td className={`text-right p-2 ${change > 0 ? 'text-red-600' : change < 0 ? 'text-green-600' : ''}`}>
|
||||
{change !== 0 ? `${change > 0 ? '+' : ''}${change.toFixed(1)}%` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutDashboard, Search, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
|
||||
import { LayoutDashboard, Search, TrendingUp, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -22,6 +22,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
const navItems = [
|
||||
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
||||
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
||||
{ icon: TrendingUp, label: 'Comparateur', path: '/compare' },
|
||||
];
|
||||
|
||||
// Add admin link for admin users
|
||||
|
||||
Reference in new issue
Block a user