last
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"ui": {
|
||||
"theme": "gruvbox_vintage_dark",
|
||||
"button_mode": "text/icon",
|
||||
"columns_desktop": 4,
|
||||
"card_density": "comfortable",
|
||||
"image_ratio": 43,
|
||||
"show_fields": {
|
||||
"price": true,
|
||||
"stock": true,
|
||||
"ratings": true,
|
||||
"badges": true
|
||||
},
|
||||
"refresh_auto_seconds": 300
|
||||
},
|
||||
"versions": {
|
||||
"frontend": "0.1.0",
|
||||
"backend_expected": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>suivi_produits</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "suivi_produit_frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "suivi_produit_frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -1587,6 +1589,16 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
@@ -1619,6 +1631,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "suivi_produit_frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,7 +10,9 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
"ui": {
|
||||
"theme": "gruvbox_vintage_dark",
|
||||
"button_mode": "text/icon",
|
||||
"columns_desktop": 3,
|
||||
"columns_desktop": 4,
|
||||
"card_density": "comfortable",
|
||||
"image_ratio": 46,
|
||||
"show_fields": {
|
||||
"price": true,
|
||||
"stock": true,
|
||||
"ratings": true,
|
||||
"badges": true
|
||||
},
|
||||
"refresh_auto_seconds": 300
|
||||
"refresh_auto_seconds": 60
|
||||
},
|
||||
"versions": {
|
||||
"frontend": "0.1.0",
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
68
frontend/public/store.svg
Normal file
68
frontend/public/store.svg
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<rect x="47.421" y="138.055" style="fill:#EBEBEC;" width="417.427" height="365.248"/>
|
||||
<rect x="47.421" y="138.055" style="fill:#D7D8D9;" width="26.089" height="365.248"/>
|
||||
<path style="fill:#CF442B;" d="M47.421,242.412c19.211,0,34.786-15.574,34.786-34.786H12.636
|
||||
C12.636,226.838,28.209,242.412,47.421,242.412L47.421,242.412z"/>
|
||||
<path style="fill:#FDD042;" d="M116.993,242.412c19.211,0,34.786-15.574,34.786-34.786H82.207
|
||||
C82.207,226.838,97.78,242.412,116.993,242.412L116.993,242.412z"/>
|
||||
<path style="fill:#CF442B;" d="M186.564,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
|
||||
C151.778,226.838,167.351,242.412,186.564,242.412L186.564,242.412z"/>
|
||||
<path style="fill:#FDD042;" d="M256.135,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
|
||||
C221.349,226.838,236.922,242.412,256.135,242.412L256.135,242.412z"/>
|
||||
<path style="fill:#CF442B;" d="M325.706,242.412c19.211,0,34.786-15.574,34.786-34.786H290.92
|
||||
C290.92,226.838,306.493,242.412,325.706,242.412L325.706,242.412z"/>
|
||||
<path style="fill:#FDD042;" d="M395.277,242.412L395.277,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
|
||||
C360.491,226.838,376.065,242.412,395.277,242.412z"/>
|
||||
<path style="fill:#CF442B;" d="M464.848,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
|
||||
C430.063,226.838,445.636,242.412,464.848,242.412L464.848,242.412z"/>
|
||||
<polygon style="fill:#E5563C;" points="56.118,7.609 108.296,7.609 82.207,207.626 12.636,207.626 "/>
|
||||
<polygon style="fill:#FDDD85;" points="108.296,7.609 169.171,7.609 151.778,207.626 82.207,207.626 "/>
|
||||
<polygon style="fill:#E5563C;" points="169.171,7.609 221.349,7.609 221.349,207.626 151.778,207.626 "/>
|
||||
<rect x="221.345" y="7.609" style="fill:#FDDD85;" width="69.571" height="200.017"/>
|
||||
<polygon style="fill:#E5563C;" points="290.92,7.609 343.099,7.609 360.491,207.626 290.92,207.626 "/>
|
||||
<polygon style="fill:#FDDD85;" points="343.099,7.609 403.973,7.609 430.063,207.626 360.491,207.626 "/>
|
||||
<polygon style="fill:#E5563C;" points="403.973,7.609 456.152,7.609 499.634,207.626 430.063,207.626 "/>
|
||||
<rect x="82.203" y="268.501" style="fill:#74757B;" width="139.142" height="234.803"/>
|
||||
<rect x="82.203" y="268.501" style="fill:#606268;" width="26.089" height="234.803"/>
|
||||
<rect x="273.523" y="268.501" style="fill:#AFF0E8;" width="139.142" height="104.357"/>
|
||||
<g>
|
||||
<polygon style="fill:#74DBC9;" points="387.668,268.501 283.311,372.858 355.056,372.858 412.67,315.244 412.67,268.501 "/>
|
||||
<polygon style="fill:#74DBC9;" points="273.528,268.501 273.528,334.811 339.838,268.501 "/>
|
||||
<polygon style="fill:#74DBC9;" points="380.602,372.858 412.67,372.858 412.67,340.79 "/>
|
||||
</g>
|
||||
<circle style="fill:#FDD042;" cx="177.863" cy="385.902" r="17.393"/>
|
||||
<path d="M4.484,207.686c0,0.023-0.271,0.046-0.271,0.068c0.062,20.831,14.944,38.211,34.511,42.024v175.259
|
||||
c0,4.504,3.65,8.153,8.153,8.153s8.153-3.649,8.153-8.153V249.779c11.958-2.106,20.619-8.346,26.904-17.024
|
||||
c7.808,10.779,20.623,17.81,34.921,17.81s27.045-7.031,34.854-17.81c7.808,10.779,20.521,17.81,34.819,17.81
|
||||
c14.298,0,26.995-7.031,34.803-17.81c7.808,10.779,20.496,17.81,34.794,17.81c14.298,0,26.982-7.031,34.79-17.81
|
||||
c7.808,10.779,20.49,17.81,34.788,17.81s26.978-7.031,34.787-17.81c7.808,10.779,20.489,17.81,34.786,17.81
|
||||
s26.706-7.031,34.514-17.81c6.285,8.677,15.49,14.919,26.361,17.024v36.117c0,4.504,3.649,8.153,8.153,8.153
|
||||
c4.504,0,8.153-3.649,8.153-8.153v-36.117c20.654-3.812,34.991-21.191,35.054-42.021c0.01-0.625,0.087-1.252-0.047-1.863
|
||||
L464.05,6.149C463.235,2.401,459.987,0,456.152,0H56.118c-3.835,0-7.152,2.401-7.967,6.149L4.669,206.031
|
||||
C4.534,206.615,4.484,207.088,4.484,207.686z M47.421,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
|
||||
C69.327,226.106,59.264,234.803,47.421,234.803z M116.993,234.803c-11.843,0-21.905-8.696-25.356-18.48h50.713
|
||||
C138.898,226.106,128.835,234.803,116.993,234.803z M186.564,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
|
||||
C208.469,226.106,198.406,234.803,186.564,234.803z M256.135,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
|
||||
C278.04,226.106,267.977,234.803,256.135,234.803z M298.53,16.306h37.094l15.974,183.711H298.53V16.306z M325.706,234.803
|
||||
c-11.843,0-21.905-8.696-25.356-18.48h50.713C347.611,226.106,337.548,234.803,325.706,234.803z M395.277,234.803
|
||||
c-11.843,0-21.905-8.696-25.357-18.48h50.713C417.182,226.106,407.119,234.803,395.277,234.803z M464.848,234.803
|
||||
c-11.843,0-21.905-8.696-25.356-18.48h50.713C486.753,226.106,476.691,234.803,464.848,234.803z M449.581,16.306l39.937,183.711
|
||||
h-52.297L413.259,16.306H449.581z M396.815,16.306l23.962,183.711h-52.811L351.991,16.306H396.815z M282.224,200.017h-53.265V16.306
|
||||
h53.265V200.017z M212.653,200.017h-51.982l15.974-183.711h36.007V200.017z M144.304,200.017H91.493l23.962-183.711h44.823
|
||||
L144.304,200.017z M62.688,16.306h36.322L75.048,200.017H22.751L62.688,16.306z"/>
|
||||
<path d="M412.67,260.892H273.528c-4.504,0-8.696,3.106-8.696,7.609v96.747h-8.696c-4.503,0-8.153,3.649-8.153,8.153
|
||||
c0,4.504,3.65,8.153,8.153,8.153h173.928c4.504,0,8.153-3.649,8.153-8.153c0-4.504-3.649-8.153-8.153-8.153h-9.783v-96.747
|
||||
C420.279,263.997,417.172,260.892,412.67,260.892z M281.137,365.248v-88.051h122.837v88.051H281.137z"/>
|
||||
<path d="M177.867,360.357c-14.086,0-25.546,11.46-25.546,25.546c0,14.086,11.46,25.546,25.546,25.546
|
||||
c14.086,0,25.546-11.46,25.546-25.546C203.413,371.816,191.953,360.357,177.867,360.357z M177.867,395.142
|
||||
c-5.095,0-9.24-4.145-9.24-9.24c0-5.095,4.145-9.24,9.24-9.24c5.095,0,9.24,4.145,9.24,9.24
|
||||
C187.107,390.997,182.962,395.142,177.867,395.142z"/>
|
||||
<path d="M499.634,495.694h-27.176V320.675c0-4.504-3.649-8.153-8.153-8.153c-4.504,0-8.153,3.649-8.153,8.153v175.019H228.959
|
||||
V268.501c0-4.504-3.107-7.609-7.609-7.609H82.207c-4.503,0-8.696,3.106-8.696,7.609v227.193h-18.48v-35.872
|
||||
c0-4.504-3.65-8.153-8.153-8.153s-8.153,3.649-8.153,8.153v35.872H12.636c-4.503,0-8.153,3.649-8.153,8.153
|
||||
c0,4.504,3.65,8.153,8.153,8.153h486.998c4.504,0,8.153-3.649,8.153-8.153C507.787,499.344,504.136,495.694,499.634,495.694z
|
||||
M89.816,495.694V277.197h122.837v218.497H89.816z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -14,6 +14,7 @@ const Header = () => {
|
||||
const { fetchProducts, scrapeAll, loading } = useProductStore();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [backendVersion, setBackendVersion] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.fetchBackendVersion()
|
||||
@@ -21,6 +22,19 @@ const Header = () => {
|
||||
.catch(() => setBackendVersion("?"));
|
||||
}, []);
|
||||
|
||||
// Fetch system stats toutes les 30 secondes
|
||||
useEffect(() => {
|
||||
const fetchStats = () => {
|
||||
api.fetchSystemStats()
|
||||
.then((data) => setStats(data))
|
||||
.catch(() => setStats(null));
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchProducts();
|
||||
};
|
||||
@@ -38,7 +52,10 @@ const Header = () => {
|
||||
<>
|
||||
<header className="app-header">
|
||||
<div className="brand">
|
||||
<NavLink to="/">suivi_produits</NavLink>
|
||||
<NavLink to="/">
|
||||
<img className="brand-logo" src="/store.svg" alt="Store" />
|
||||
suivi_produits
|
||||
</NavLink>
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/" end className={({ isActive }) => isActive ? "active" : ""}>
|
||||
@@ -62,6 +79,19 @@ const Header = () => {
|
||||
<i className={`fa-solid fa-refresh ${loading ? "fa-spin" : ""}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="system-stats">
|
||||
<span title="CPU du process backend">
|
||||
<i className="fa-solid fa-microchip"></i> {stats.cpu_percent}%
|
||||
</span>
|
||||
<span title="Mémoire du process backend">
|
||||
<i className="fa-solid fa-memory"></i> {stats.memory_mb} Mo
|
||||
</span>
|
||||
<span title="Taille données (data + logs)">
|
||||
<i className="fa-solid fa-database"></i> {stats.data_size_mb < 1000 ? `${stats.data_size_mb} Mo` : `${(stats.data_size_mb / 1024).toFixed(1)} Go`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="version-info">
|
||||
<span title="Version Frontend">FE v{FRONTEND_VERSION}</span>
|
||||
<span title="Version Backend">BE v{backendVersion || "..."}</span>
|
||||
|
||||
@@ -75,8 +75,8 @@ export const scrapePreview = async (url) => {
|
||||
};
|
||||
|
||||
// Snapshots
|
||||
export const fetchSnapshots = async (productId, limit = 30) => {
|
||||
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?limit=${limit}`);
|
||||
export const fetchSnapshots = async (productId, days = 30) => {
|
||||
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?days=${days}`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
@@ -126,3 +126,31 @@ export const fetchBackendVersion = async () => {
|
||||
const response = await fetch(`${BASE_URL}/version`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
// System stats
|
||||
export const fetchSystemStats = async () => {
|
||||
const response = await fetch(`${BASE_URL}/stats`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
// Database backup
|
||||
export const fetchDatabaseInfo = async () => {
|
||||
const response = await fetch(`${BASE_URL}/config/database/info`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
export const downloadDatabaseBackup = () => {
|
||||
// Téléchargement direct via le navigateur
|
||||
window.location.href = `${BASE_URL}/config/database/backup`;
|
||||
};
|
||||
|
||||
export const restoreDatabase = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/config/database/restore`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
Tooltip,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import annotationPlugin from "chartjs-plugin-annotation";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import * as api from "../../api/client";
|
||||
|
||||
// Enregistrer les composants Chart.js
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
@@ -174,8 +176,15 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const labels = sortedSnapshots.map((s) => formatDateLabel(s.scrape_le, spanDays));
|
||||
const prices = sortedSnapshots.map((s) => s.prix_actuel);
|
||||
// Données au format {x: Date, y: prix} pour TimeScale
|
||||
const chartPoints = sortedSnapshots
|
||||
.filter((s) => s.prix_actuel != null)
|
||||
.map((s) => ({
|
||||
x: parseUTCDate(s.scrape_le),
|
||||
y: s.prix_actuel,
|
||||
}));
|
||||
|
||||
const prices = chartPoints.map((p) => p.y);
|
||||
|
||||
// Calculer min/max
|
||||
const validPrices = prices.filter((p) => p != null);
|
||||
@@ -229,10 +238,9 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: prices,
|
||||
data: chartPoints,
|
||||
borderColor: trend.color,
|
||||
backgroundColor: `${trend.color}20`,
|
||||
fill: true,
|
||||
@@ -245,6 +253,14 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
],
|
||||
};
|
||||
|
||||
// Déterminer l'unité de temps appropriée selon la période
|
||||
const getTimeUnit = () => {
|
||||
if (spanDays < 1) return "hour";
|
||||
if (spanDays <= 7) return "day";
|
||||
if (spanDays <= 90) return "week";
|
||||
return "month";
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -260,7 +276,18 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: (context) => formatPrice(context.raw),
|
||||
title: (context) => {
|
||||
const date = context[0]?.raw?.x;
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
label: (context) => formatPrice(context.raw?.y),
|
||||
},
|
||||
},
|
||||
annotation: {
|
||||
@@ -269,6 +296,21 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
unit: getTimeUnit(),
|
||||
displayFormats: {
|
||||
hour: "HH:mm",
|
||||
day: "dd/MM",
|
||||
week: "dd/MM",
|
||||
month: "MMM yyyy",
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
date: {
|
||||
locale: fr,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
@@ -276,7 +318,7 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
color: COLORS.muted,
|
||||
font: { size: 10 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Lightbox from "../common/Lightbox";
|
||||
import useProductStore from "../../stores/useProductStore";
|
||||
|
||||
const formatPrice = (price) => {
|
||||
if (price == null) return null;
|
||||
@@ -30,6 +31,41 @@ const formatDate = (dateStr) => {
|
||||
|
||||
const ProductDetailModal = ({ product, onClose }) => {
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
titre: product.titre || "",
|
||||
categorie: product.categorie || "",
|
||||
type: product.type || "",
|
||||
actif: product.actif ?? true,
|
||||
});
|
||||
const { updateProduct } = useProductStore();
|
||||
|
||||
const handleEditChange = (field, value) => {
|
||||
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateProduct(product.id, editForm);
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la sauvegarde:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditForm({
|
||||
titre: product.titre || "",
|
||||
categorie: product.categorie || "",
|
||||
type: product.type || "",
|
||||
actif: product.actif ?? true,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -57,9 +93,29 @@ const ProductDetailModal = ({ product, onClose }) => {
|
||||
<h2>
|
||||
<i className="fa-brands fa-amazon"></i> Détail du produit
|
||||
</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<i className="fa-solid fa-times"></i>
|
||||
</button>
|
||||
<div className="modal-header-actions">
|
||||
{!isEditing ? (
|
||||
<button className="btn btn-edit" onClick={() => setIsEditing(true)}>
|
||||
<i className="fa-solid fa-pen"></i> Éditer
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-save" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin"></i> Enregistrement...</>
|
||||
) : (
|
||||
<><i className="fa-solid fa-check"></i> Enregistrer</>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn btn-cancel" onClick={handleCancelEdit} disabled={isSaving}>
|
||||
<i className="fa-solid fa-times"></i> Annuler
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<i className="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
@@ -90,14 +146,37 @@ const ProductDetailModal = ({ product, onClose }) => {
|
||||
<span className="boutique">
|
||||
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
||||
</span>
|
||||
{product.actif ? (
|
||||
<span className="status active">Actif</span>
|
||||
{isEditing ? (
|
||||
<label className="status-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.actif}
|
||||
onChange={(e) => handleEditChange("actif", e.target.checked)}
|
||||
/>
|
||||
<span className={`status ${editForm.actif ? "active" : "inactive"}`}>
|
||||
{editForm.actif ? "Actif" : "Inactif"}
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<span className="status inactive">Inactif</span>
|
||||
product.actif ? (
|
||||
<span className="status active">Actif</span>
|
||||
) : (
|
||||
<span className="status inactive">Inactif</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input edit-title"
|
||||
value={editForm.titre}
|
||||
onChange={(e) => handleEditChange("titre", e.target.value)}
|
||||
placeholder="Titre du produit"
|
||||
/>
|
||||
) : (
|
||||
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
{hasBadges && (
|
||||
@@ -197,18 +276,40 @@ const ProductDetailModal = ({ product, onClose }) => {
|
||||
<span className="label">ASIN</span>
|
||||
<span className="value mono">{product.asin}</span>
|
||||
</div>
|
||||
{product.categorie && (
|
||||
{product.categorie_amazon && (
|
||||
<div className="detail-data-row">
|
||||
<span className="label">Catégorie</span>
|
||||
<span className="value">{product.categorie}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.type && (
|
||||
<div className="detail-data-row">
|
||||
<span className="label">Type</span>
|
||||
<span className="value">{product.type}</span>
|
||||
<span className="label">Catégorie Amazon</span>
|
||||
<span className="value category-path">{product.categorie_amazon}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-data-row">
|
||||
<span className="label">Catégorie</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input"
|
||||
value={editForm.categorie}
|
||||
onChange={(e) => handleEditChange("categorie", e.target.value)}
|
||||
placeholder="Catégorie personnalisée"
|
||||
/>
|
||||
) : (
|
||||
<span className="value">{product.categorie || "-"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-data-row">
|
||||
<span className="label">Type</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input"
|
||||
value={editForm.type}
|
||||
onChange={(e) => handleEditChange("type", e.target.value)}
|
||||
placeholder="Type de produit"
|
||||
/>
|
||||
) : (
|
||||
<span className="value">{product.type || "-"}</span>
|
||||
)}
|
||||
</div>
|
||||
{product.cree_le && (
|
||||
<div className="detail-data-row">
|
||||
<span className="label">Ajouté le</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import useProductStore from "../stores/useProductStore";
|
||||
import useConfigStore from "../stores/useConfigStore";
|
||||
import ProductGrid from "../components/products/ProductGrid";
|
||||
@@ -6,12 +6,39 @@ import ProductGrid from "../components/products/ProductGrid";
|
||||
const HomePage = () => {
|
||||
const { products, loading, error, fetchProducts, clearError } = useProductStore();
|
||||
const { config, fetchConfig } = useConfigStore();
|
||||
const refreshIntervalRef = useRef(null);
|
||||
|
||||
// Chargement initial
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchProducts();
|
||||
}, [fetchProducts, fetchConfig]);
|
||||
|
||||
// Auto-refresh basé sur la config (rechargement complet de la page)
|
||||
useEffect(() => {
|
||||
const refreshInterval = config.ui?.refresh_auto_seconds || 0;
|
||||
|
||||
// Nettoyer l'ancien intervalle
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Configurer le nouvel intervalle si > 0
|
||||
if (refreshInterval > 0) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
window.location.reload();
|
||||
}, refreshInterval * 1000);
|
||||
}
|
||||
|
||||
// Cleanup à la destruction du composant
|
||||
return () => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [config.ui?.refresh_auto_seconds]);
|
||||
|
||||
const columns = config.ui?.columns_desktop || 3;
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,6 +56,11 @@ const useConfigStore = create((set) => ({
|
||||
return state.config.ui?.image_ratio || 40;
|
||||
},
|
||||
|
||||
getRefreshInterval: () => {
|
||||
const state = useConfigStore.getState();
|
||||
return state.config.ui?.refresh_auto_seconds || 0;
|
||||
},
|
||||
|
||||
// Setter local pour mise à jour en temps réel (sans sauvegarder)
|
||||
setImageRatioLocal: (ratio) => {
|
||||
set((state) => ({
|
||||
|
||||
@@ -54,6 +54,9 @@ a {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $text;
|
||||
&:hover {
|
||||
color: $accent;
|
||||
@@ -61,6 +64,11 @@ a {
|
||||
}
|
||||
}
|
||||
}
|
||||
.brand-logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@@ -93,6 +101,28 @@ a {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: $gray;
|
||||
font-family: monospace;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: rgba($card, 0.5);
|
||||
border-radius: 4px;
|
||||
|
||||
i {
|
||||
color: $accent;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
Reference in New Issue
Block a user