feat: amélioration UI - popup logs, ratio image, lien Amazon header

- Ajout popup de logs en bas à droite pour les commandes backend
- Ajout slider ratio image/infos dans Settings (mise à jour temps réel)
- Déplacement du lien "Voir sur Amazon" dans le header de la carte
- Amélioration du formatage des dates dans le graphique (adaptatif selon span)
- Ajout lignes de référence prix conseillé/min 30j dans PriceChart
- Ajout sélecteur de période (7j/30j/90j/Tout) dans le graphique

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 22:36:42 +01:00
parent 58e9aa1429
commit b89e9e15df
12 changed files with 850 additions and 35 deletions

View File

@@ -2,8 +2,9 @@
"ui": {
"theme": "gruvbox_vintage_dark",
"button_mode": "text/icon",
"columns_desktop": 3,
"columns_desktop": 4,
"card_density": "comfortable",
"image_ratio": 43,
"show_fields": {
"price": true,
"stock": true,

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.1",
@@ -1586,6 +1587,15 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.1",

View File

@@ -5,6 +5,7 @@ import DebugPage from "./pages/DebugPage";
import SettingsPage from "./pages/SettingsPage";
import useProductStore from "./stores/useProductStore";
import AddProductModal from "./components/products/AddProductModal";
import CommandLogPopup from "./components/common/CommandLogPopup";
import * as api from "./api/client";
const FRONTEND_VERSION = "0.1.0";
@@ -83,6 +84,7 @@ const App = () => (
<Route path="/debug" element={<DebugPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<CommandLogPopup />
</div>
</BrowserRouter>
);

View File

@@ -9,6 +9,7 @@ import {
Tooltip,
Filler,
} from "chart.js";
import annotationPlugin from "chartjs-plugin-annotation";
import { Line } from "react-chartjs-2";
import * as api from "../../api/client";
@@ -20,7 +21,8 @@ ChartJS.register(
LineElement,
Title,
Tooltip,
Filler
Filler,
annotationPlugin
);
// Couleurs Gruvbox
@@ -32,8 +34,17 @@ const COLORS = {
text: "#ebdbb2",
muted: "#928374",
bg: "#282828",
purple: "#d3869b",
};
// Périodes disponibles
const PERIODS = [
{ label: "7j", days: 7 },
{ label: "30j", days: 30 },
{ label: "90j", days: 90 },
{ label: "Tout", days: 365 },
];
// Convertit une date UTC (sans timezone) en objet Date local
const parseUTCDate = (dateStr) => {
if (!dateStr) return null;
@@ -42,10 +53,26 @@ const parseUTCDate = (dateStr) => {
return new Date(normalized);
};
const formatDate = (dateStr) => {
const formatDateLabel = (dateStr, spanDays) => {
const date = parseUTCDate(dateStr);
if (!date) return "-";
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
// Adapter le format selon l'écart réel entre les dates
if (spanDays < 1) {
// Moins d'un jour : afficher uniquement l'heure
return date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
} else if (spanDays <= 7) {
// 1 à 7 jours : afficher date + heure
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} else {
// Plus de 7 jours : afficher uniquement la date
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
}
};
const formatPrice = (price) => {
@@ -56,6 +83,15 @@ const formatPrice = (price) => {
}).format(price);
};
const formatPriceShort = (price) => {
if (price == null) return "-";
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(price);
};
const calculateTrend = (snapshots) => {
if (!snapshots || snapshots.length < 2) {
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
@@ -81,16 +117,17 @@ const calculateTrend = (snapshots) => {
return { direction: "stable", percent: percentChange, color: COLORS.yellow, arrow: "→" };
};
const PriceChart = ({ productId }) => {
const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
const [snapshots, setSnapshots] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedPeriod, setSelectedPeriod] = useState(30);
useEffect(() => {
const fetchSnapshots = async () => {
try {
setLoading(true);
const data = await api.fetchSnapshots(productId, 30);
const data = await api.fetchSnapshots(productId, selectedPeriod);
setSnapshots(data);
setError(null);
} catch (err) {
@@ -101,7 +138,7 @@ const PriceChart = ({ productId }) => {
};
fetchSnapshots();
}, [productId]);
}, [productId, selectedPeriod]);
if (loading) {
return (
@@ -126,7 +163,18 @@ const PriceChart = ({ productId }) => {
// Préparer les données (inverser pour ordre chronologique)
const sortedSnapshots = [...snapshots].reverse();
const labels = sortedSnapshots.map((s) => formatDate(s.scrape_le));
// Calculer le span réel entre la première et dernière date
let spanDays = 0;
if (sortedSnapshots.length >= 2) {
const firstDate = parseUTCDate(sortedSnapshots[0].scrape_le);
const lastDate = parseUTCDate(sortedSnapshots[sortedSnapshots.length - 1].scrape_le);
if (firstDate && lastDate) {
spanDays = (lastDate - firstDate) / (1000 * 60 * 60 * 24);
}
}
const labels = sortedSnapshots.map((s) => formatDateLabel(s.scrape_le, spanDays));
const prices = sortedSnapshots.map((s) => s.prix_actuel);
// Calculer min/max
@@ -137,6 +185,49 @@ const PriceChart = ({ productId }) => {
// Calculer la tendance
const trend = calculateTrend(snapshots);
// Préparer les annotations (lignes de référence)
const annotations = {};
// Ligne prix conseillé (si disponible et dans une plage raisonnable)
if (prixConseille && prixConseille > maxPrice * 0.8) {
annotations.prixConseille = {
type: "line",
yMin: prixConseille,
yMax: prixConseille,
borderColor: COLORS.muted,
borderWidth: 1,
borderDash: [5, 5],
label: {
display: true,
content: `Conseillé ${formatPriceShort(prixConseille)}`,
position: "end",
backgroundColor: "transparent",
color: COLORS.muted,
font: { size: 9 },
},
};
}
// Ligne prix min 30j (si disponible)
if (prixMin30j && prixMin30j <= minPrice * 1.1) {
annotations.prixMin30j = {
type: "line",
yMin: prixMin30j,
yMax: prixMin30j,
borderColor: COLORS.green,
borderWidth: 1,
borderDash: [3, 3],
label: {
display: true,
content: `Min 30j ${formatPriceShort(prixMin30j)}`,
position: "start",
backgroundColor: "transparent",
color: COLORS.green,
font: { size: 9 },
},
};
}
const chartData = {
labels,
datasets: [
@@ -172,6 +263,9 @@ const PriceChart = ({ productId }) => {
label: (context) => formatPrice(context.raw),
},
},
annotation: {
annotations,
},
},
scales: {
x: {
@@ -182,7 +276,7 @@ const PriceChart = ({ productId }) => {
color: COLORS.muted,
font: { size: 10 },
maxRotation: 0,
maxTicksLimit: 5,
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
},
},
y: {
@@ -192,8 +286,10 @@ const PriceChart = ({ productId }) => {
ticks: {
color: COLORS.muted,
font: { size: 10 },
callback: (value) => formatPrice(value),
callback: (value) => formatPriceShort(value),
},
// Étendre l'échelle Y pour inclure le prix conseillé si présent
suggestedMax: prixConseille ? Math.max(maxPrice, prixConseille) * 1.02 : undefined,
},
},
};
@@ -210,6 +306,19 @@ const PriceChart = ({ productId }) => {
return (
<div className="price-chart-container">
{/* Sélecteur de période */}
<div className="price-chart-period-selector">
{PERIODS.map((p) => (
<button
key={p.days}
className={`period-btn ${selectedPeriod === p.days ? "active" : ""}`}
onClick={() => setSelectedPeriod(p.days)}
>
{p.label}
</button>
))}
</div>
<div className="price-chart">
<Line data={chartData} options={chartOptions} />
</div>

View File

@@ -0,0 +1,49 @@
import useCommandLogStore from "../../stores/useCommandLogStore";
const CommandLogPopup = () => {
const { logs, visible, hide } = useCommandLogStore();
if (!visible || logs.length === 0) {
return null;
}
// Afficher les 5 derniers logs (les plus récents en premier)
const displayLogs = logs.slice(0, 5);
const getTypeIcon = (type) => {
switch (type) {
case "success":
return "fa-check-circle";
case "error":
return "fa-times-circle";
case "warning":
return "fa-exclamation-triangle";
default:
return "fa-info-circle";
}
};
return (
<div className="command-log-popup">
<div className="command-log-header">
<span className="command-log-title">
<i className="fa-solid fa-terminal"></i> Logs
</span>
<button className="command-log-close" onClick={hide} title="Fermer">
<i className="fa-solid fa-times"></i>
</button>
</div>
<div className="command-log-content">
{displayLogs.map((log) => (
<div key={log.id} className={`command-log-line ${log.type}`}>
<span className="log-timestamp">{log.timestamp}</span>
<i className={`fa-solid ${getTypeIcon(log.type)} log-icon`}></i>
<span className="log-message">{log.message}</span>
</div>
))}
</div>
</div>
);
};
export default CommandLogPopup;

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react";
import useProductStore from "../../stores/useProductStore";
import useConfigStore from "../../stores/useConfigStore";
import PriceChart from "../charts/PriceChart";
import Lightbox from "../common/Lightbox";
import ProductDetailModal from "./ProductDetailModal";
@@ -19,6 +20,7 @@ const formatNumber = (num) => {
const ProductCard = ({ product }) => {
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
const imageRatio = useConfigStore((state) => state.config.ui?.image_ratio || 40);
const isScraping = scraping[product.id];
const [showLightbox, setShowLightbox] = useState(false);
const [showDetail, setShowDetail] = useState(false);
@@ -52,11 +54,22 @@ const ProductCard = ({ product }) => {
<span className="boutique">
<i className="fa-brands fa-amazon"></i> {product.boutique}
</span>
{product.actif ? (
<span className="status active">Actif</span>
) : (
<span className="status inactive">Inactif</span>
)}
<div className="header-right">
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="header-link"
title="Voir sur Amazon"
>
<i className="fa-solid fa-external-link"></i>
</a>
{product.actif ? (
<span className="status active">Actif</span>
) : (
<span className="status inactive">Inactif</span>
)}
</div>
</div>
<h3
@@ -67,7 +80,7 @@ const ProductCard = ({ product }) => {
{product.titre || "Titre non disponible"}
</h3>
<div className="product-body">
<div className="product-body" style={{ "--image-ratio": imageRatio }}>
<div
className={`product-image ${product.url_image ? "clickable" : ""}`}
onClick={() => product.url_image && setShowLightbox(true)}
@@ -169,22 +182,21 @@ const ProductCard = ({ product }) => {
{/* Méta */}
<div className="product-meta">
<span className="asin">Ref: {product.asin}</span>
{product.categorie && <span className="category">{product.categorie}</span>}
{product.categorie_amazon && (
<span className="category" title={product.categorie_amazon}>
<i className="fa-solid fa-folder-tree"></i> {product.categorie_amazon.split(" > ").slice(-1)[0]}
</span>
)}
</div>
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="product-link"
>
<i className="fa-solid fa-external-link"></i> Voir sur Amazon
</a>
</div>
</div>
{/* Graphique historique 30j */}
<PriceChart productId={product.id} />
{/* Graphique historique */}
<PriceChart
productId={product.id}
prixConseille={product.prix_conseille}
prixMin30j={product.prix_min_30j}
/>
<div className="product-actions">
<button

View File

@@ -1,13 +1,34 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import * as api from "../api/client";
import useConfigStore from "../stores/useConfigStore";
const formatDate = (dateStr) => {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const SettingsPage = () => {
const [frontendConfig, setFrontendConfig] = useState(null);
const [backendConfig, setBackendConfig] = useState(null);
const [dbInfo, setDbInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [restoring, setRestoring] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
loadConfigs();
@@ -17,12 +38,14 @@ const SettingsPage = () => {
setLoading(true);
setError(null);
try {
const [fe, be] = await Promise.all([
const [fe, be, db] = await Promise.all([
api.fetchFrontendConfig(),
api.fetchBackendConfig(),
api.fetchDatabaseInfo().catch(() => null),
]);
setFrontendConfig(fe);
setBackendConfig(be);
setDbInfo(db);
} catch (err) {
setError("Erreur lors du chargement des configurations: " + err.message);
} finally {
@@ -62,6 +85,47 @@ const SettingsPage = () => {
}
};
const handleDownloadBackup = () => {
api.downloadDatabaseBackup();
};
const handleRestoreClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".db")) {
setError("Le fichier doit être un fichier .db");
return;
}
if (!confirm("Êtes-vous sûr de vouloir restaurer cette base de données ? Cette action remplacera toutes les données actuelles.")) {
return;
}
setRestoring(true);
setError(null);
setSuccess(null);
try {
const result = await api.restoreDatabase(file);
setSuccess(`Base de données restaurée (${formatSize(result.size_bytes)})`);
// Recharger les infos
const db = await api.fetchDatabaseInfo();
setDbInfo(db);
setTimeout(() => setSuccess(null), 5000);
} catch (err) {
setError("Erreur restauration: " + err.message);
} finally {
setRestoring(false);
// Reset l'input file
e.target.value = "";
}
};
if (loading) {
return (
<main className="settings-page">
@@ -156,6 +220,31 @@ const SettingsPage = () => {
</select>
</div>
<div className="form-group">
<label>Ratio image / infos</label>
<div className="slider-group">
<span className="slider-label-side">Info</span>
<input
type="range"
min="20"
max="60"
value={frontendConfig.ui?.image_ratio || 40}
onChange={(e) => {
const ratio = parseInt(e.target.value);
setFrontendConfig({
...frontendConfig,
ui: { ...frontendConfig.ui, image_ratio: ratio },
});
// Mise à jour temps réel sans sauvegarde
useConfigStore.getState().setImageRatioLocal(ratio);
}}
/>
<span className="slider-label-side">Image</span>
<span className="slider-value">{frontendConfig.ui?.image_ratio || 40}%</span>
</div>
<span className="form-hint">Proportion de l'image dans la vignette produit</span>
</div>
<div className="form-group">
<label>Rafraîchissement auto (secondes)</label>
<input
@@ -401,6 +490,67 @@ const SettingsPage = () => {
</div>
)}
</section>
{/* Section Database Backup */}
<section className="settings-section">
<div className="section-header">
<h3>
<i className="fa-solid fa-database"></i> Base de données
</h3>
</div>
<div className="settings-form">
{dbInfo && (
<div className="db-info">
<div className="db-info-row">
<span className="label">Fichier</span>
<span className="value mono">{dbInfo.filename}</span>
</div>
<div className="db-info-row">
<span className="label">Taille</span>
<span className="value">{formatSize(dbInfo.size_bytes)}</span>
</div>
<div className="db-info-row">
<span className="label">Dernière modification</span>
<span className="value">{formatDate(dbInfo.modified_at)}</span>
</div>
</div>
)}
<div className="db-actions">
<button
className="btn btn-primary"
onClick={handleDownloadBackup}
>
<i className="fa-solid fa-download"></i> Télécharger backup
</button>
<button
className="btn btn-secondary"
onClick={handleRestoreClick}
disabled={restoring}
>
{restoring ? (
<><i className="fa-solid fa-spinner fa-spin"></i> Restauration...</>
) : (
<><i className="fa-solid fa-upload"></i> Restaurer</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept=".db"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
</div>
<span className="form-hint">
La restauration créera automatiquement une sauvegarde de la base actuelle avant de la remplacer.
</span>
</div>
</section>
</div>
</main>
);

View File

@@ -0,0 +1,65 @@
import { create } from "zustand";
const MAX_LOGS = 50;
const AUTO_HIDE_DELAY = 5000; // 5 secondes après la dernière activité
const useCommandLogStore = create((set, get) => ({
logs: [],
visible: false,
autoHideTimer: null,
// Ajouter un log
addLog: (message, type = "info") => {
const timestamp = new Date().toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
set((state) => {
// Annuler le timer existant
if (state.autoHideTimer) {
clearTimeout(state.autoHideTimer);
}
// Nouveau timer pour auto-hide
const newTimer = setTimeout(() => {
set({ visible: false });
}, AUTO_HIDE_DELAY);
const newLogs = [
{ id: Date.now(), timestamp, message, type },
...state.logs,
].slice(0, MAX_LOGS);
return {
logs: newLogs,
visible: true,
autoHideTimer: newTimer,
};
});
},
// Types de logs spécifiques
logInfo: (message) => get().addLog(message, "info"),
logSuccess: (message) => get().addLog(message, "success"),
logError: (message) => get().addLog(message, "error"),
logWarning: (message) => get().addLog(message, "warning"),
// Masquer le popup
hide: () => {
const state = get();
if (state.autoHideTimer) {
clearTimeout(state.autoHideTimer);
}
set({ visible: false, autoHideTimer: null });
},
// Afficher le popup
show: () => set({ visible: true }),
// Vider les logs
clear: () => set({ logs: [], visible: false }),
}));
export default useCommandLogStore;

View File

@@ -8,6 +8,7 @@ const DEFAULT_CONFIG = {
button_mode: "text/icon",
columns_desktop: 3,
card_density: "comfortable",
image_ratio: 40, // % de la hauteur du body pour l'image (20-60)
show_fields: {
price: true,
stock: true,
@@ -49,6 +50,24 @@ const useConfigStore = create((set) => ({
const state = useConfigStore.getState();
return state.config.ui?.show_fields || DEFAULT_CONFIG.ui.show_fields;
},
getImageRatio: () => {
const state = useConfigStore.getState();
return state.config.ui?.image_ratio || 40;
},
// Setter local pour mise à jour en temps réel (sans sauvegarder)
setImageRatioLocal: (ratio) => {
set((state) => ({
config: {
...state.config,
ui: {
...state.config.ui,
image_ratio: ratio,
},
},
}));
},
}));
export default useConfigStore;

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import * as api from "../api/client";
import useCommandLogStore from "./useCommandLogStore";
const useProductStore = create((set, get) => ({
// State
@@ -11,46 +12,58 @@ const useProductStore = create((set, get) => ({
// Actions
fetchProducts: async () => {
set({ loading: true, error: null });
useCommandLogStore.getState().logInfo("Chargement des produits...");
try {
const products = await api.fetchProducts();
set({ products, loading: false });
useCommandLogStore.getState().logSuccess(`${products.length} produits chargés`);
} catch (err) {
set({ error: err.message, loading: false });
useCommandLogStore.getState().logError(`Erreur: ${err.message}`);
}
},
addProduct: async (data) => {
set({ loading: true, error: null });
useCommandLogStore.getState().logInfo("Ajout du produit...");
try {
const newProduct = await api.createProduct(data);
// Rafraîchir la liste complète pour avoir les données enrichies (ProductWithSnapshot)
await get().fetchProducts();
set({ loading: false });
useCommandLogStore.getState().logSuccess(`Produit ajouté: ${newProduct.titre || newProduct.asin}`);
return newProduct;
} catch (err) {
set({ error: err.message, loading: false });
useCommandLogStore.getState().logError(`Erreur ajout: ${err.message}`);
throw err;
}
},
deleteProduct: async (id) => {
set({ error: null });
useCommandLogStore.getState().logInfo(`Suppression du produit #${id}...`);
try {
await api.deleteProduct(id);
set((state) => ({
products: state.products.filter((p) => p.id !== id),
}));
useCommandLogStore.getState().logSuccess(`Produit #${id} supprimé`);
} catch (err) {
set({ error: err.message });
useCommandLogStore.getState().logError(`Erreur suppression: ${err.message}`);
throw err;
}
},
scrapeProduct: async (id) => {
const product = get().products.find((p) => p.id === id);
const productName = product?.titre?.substring(0, 30) || `#${id}`;
set((state) => ({
scraping: { ...state.scraping, [id]: true },
error: null,
}));
useCommandLogStore.getState().logInfo(`Scraping: ${productName}...`);
try {
const result = await api.scrapeProduct(id);
// Refresh la liste pour avoir les nouvelles données
@@ -59,25 +72,48 @@ const useProductStore = create((set, get) => ({
const { [id]: _, ...rest } = state.scraping;
return { scraping: rest };
});
const status = result.status === "success" ? "logSuccess" : "logWarning";
useCommandLogStore.getState()[status](`Scrape ${productName}: ${result.status}`);
return result;
} catch (err) {
set((state) => {
const { [id]: _, ...rest } = state.scraping;
return { scraping: rest, error: err.message };
});
useCommandLogStore.getState().logError(`Erreur scrape: ${err.message}`);
throw err;
}
},
scrapeAll: async () => {
set({ loading: true, error: null });
useCommandLogStore.getState().logInfo("Scraping de tous les produits...");
try {
const result = await api.scrapeAll();
await get().fetchProducts();
set({ loading: false });
useCommandLogStore.getState().logSuccess(
`Scrape terminé: ${result.success}/${result.total} réussis`
);
return result;
} catch (err) {
set({ error: err.message, loading: false });
useCommandLogStore.getState().logError(`Erreur scrape all: ${err.message}`);
throw err;
}
},
updateProduct: async (id, data) => {
set({ error: null });
useCommandLogStore.getState().logInfo(`Mise à jour produit #${id}...`);
try {
await api.updateProduct(id, data);
// Rafraîchir la liste pour avoir les données mises à jour
await get().fetchProducts();
useCommandLogStore.getState().logSuccess(`Produit #${id} mis à jour`);
} catch (err) {
set({ error: err.message });
useCommandLogStore.getState().logError(`Erreur mise à jour: ${err.message}`);
throw err;
}
},

View File

@@ -248,6 +248,33 @@ a {
color: $accent;
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.header-link {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 4px;
color: $text-muted;
transition: all 0.2s;
&:hover {
background: $card;
color: $accent;
text-decoration: none;
}
i {
font-size: 0.8rem;
}
}
.status {
font-size: 0.75rem;
padding: 2px 8px;
@@ -287,6 +314,7 @@ a {
}
.product-body {
--image-ratio: 40; // Valeur par défaut, override par CSS variable
display: flex;
gap: 16px;
padding: 16px;
@@ -294,9 +322,12 @@ a {
}
.product-image {
flex-shrink: 0;
width: 120px;
height: 120px;
// Taille basée sur le ratio (variable CSS --image-ratio en %)
// flex-basis utilise le ratio pour le partage horizontal
flex: 0 0 calc(var(--image-ratio) * 1%);
max-width: calc(var(--image-ratio) * 1%);
min-width: 60px;
aspect-ratio: 1;
background: #fff;
border-radius: 8px;
display: flex;
@@ -305,6 +336,7 @@ a {
overflow: hidden;
padding: 8px;
position: relative;
transition: flex-basis 0.2s ease, max-width 0.2s ease;
&.clickable {
cursor: zoom-in;
@@ -340,11 +372,14 @@ a {
}
.product-info {
flex: 1;
// Prend le reste de l'espace (100% - image ratio)
flex: 1 1 calc((100 - var(--image-ratio)) * 1%);
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
max-height: 200px; // Limite pour éviter débordement
}
.product-title {
@@ -543,6 +578,34 @@ a {
color: $accent-red;
}
.price-chart-period-selector {
display: flex;
gap: 4px;
margin-bottom: 8px;
.period-btn {
background: transparent;
border: 1px solid $text-muted;
color: $text-muted;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $text;
color: $text;
}
&.active {
background: $accent;
border-color: $accent;
color: $bg;
}
}
}
.price-chart-stats {
display: flex;
flex-wrap: wrap;
@@ -1614,12 +1677,20 @@ a {
}
.slider-value {
min-width: 30px;
min-width: 40px;
text-align: center;
font-weight: 600;
font-size: 1.1rem;
color: $accent;
}
.slider-label-side {
font-size: 0.75rem;
color: $text-muted;
text-transform: uppercase;
min-width: 35px;
text-align: center;
}
}
.checkbox-group {
@@ -1687,3 +1758,293 @@ a {
background: $text-muted;
}
}
// Mode édition dans ProductDetailModal
.modal-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-edit {
background: $card;
color: $accent;
border: 1px solid $accent;
&:hover {
background: $accent;
color: $bg;
}
}
.btn-save {
background: $accent-green;
color: $bg;
&:hover {
background: darken($accent-green, 10%);
}
}
.btn-cancel {
background: $card;
color: $text-muted;
&:hover {
background: $card-hover;
color: $text;
}
}
.edit-input {
background: $bg;
border: 1px solid $card-hover;
border-radius: 6px;
padding: 8px 12px;
color: $text;
font-size: 0.9rem;
width: 100%;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $accent;
}
&::placeholder {
color: $text-muted;
}
}
.edit-title {
font-size: 1.25rem;
font-weight: 600;
padding: 12px 16px;
margin-bottom: 16px;
}
.status-toggle {
display: flex;
align-items: center;
cursor: pointer;
gap: 8px;
input[type="checkbox"] {
appearance: none;
width: 40px;
height: 22px;
background: $card-hover;
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.2s;
&::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: $text;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
&:checked {
background: $accent-green;
&::before {
transform: translateX(18px);
}
}
}
}
.detail-data-row .edit-input {
flex: 1;
min-width: 0;
}
// Database backup section
.db-info {
background: $bg;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.db-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
&:not(:last-child) {
border-bottom: 1px solid $card-hover;
}
.label {
color: $text-muted;
font-size: 0.85rem;
}
.value {
color: $text;
font-size: 0.9rem;
&.mono {
font-family: "JetBrains Mono", "Fira Code", monospace;
}
}
}
.db-actions {
display: flex;
gap: 12px;
margin-bottom: 12px;
.btn-secondary {
background: $card;
color: $text;
border: 1px solid $text-muted;
&:hover {
background: $card-hover;
border-color: $text;
}
}
}
// Command Log Popup
.command-log-popup {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
max-width: calc(100vw - 40px);
background: $bg;
border: 1px solid $card-hover;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba($accent, 0.1);
z-index: 1000;
overflow: hidden;
animation: slideIn 0.3s ease-out;
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.command-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: $bg-soft;
border-bottom: 1px solid $card-hover;
}
.command-log-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 600;
color: $accent;
i {
font-size: 0.9rem;
}
}
.command-log-close {
background: none;
border: none;
color: $text-muted;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
&:hover {
background: $card;
color: $text;
}
}
.command-log-content {
padding: 8px 0;
max-height: 180px;
overflow-y: auto;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.75rem;
}
.command-log-line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 14px;
animation: fadeIn 0.2s ease-out;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
&:hover {
background: rgba($card, 0.5);
}
.log-timestamp {
color: $text-muted;
flex-shrink: 0;
font-size: 0.7rem;
}
.log-icon {
flex-shrink: 0;
font-size: 0.7rem;
margin-top: 2px;
}
.log-message {
color: $text;
word-break: break-word;
line-height: 1.4;
}
// Types
&.info {
.log-icon { color: $accent-aqua; }
}
&.success {
.log-icon { color: $accent-green; }
.log-message { color: $accent-green; }
}
&.error {
.log-icon { color: $accent-red; }
.log-message { color: $accent-red; }
}
&.warning {
.log-icon { color: $accent-yellow; }
.log-message { color: $accent-yellow; }
}
}