feat(liste): ajout des catégories d'articles avec détection automatique
Deploy HouseHub / deploy (push) Successful in 1s

- 22 catégories prédéfinies (Fruits & Légumes, Viande, Frais, etc.)
- Détection automatique par dictionnaire ~250 mots-clés français
- Apprentissage des corrections manuelles (pf_item_category_rules)
- Groupement des articles par section dans l'interface
- Badge catégorie cliquable sur chaque article avec popover de sélection
- API: GET categories, POST set_category
- schema_family.sql: category_id + pf_list_categories + pf_item_category_rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-19 19:25:09 +02:00
co-authored by Claude Sonnet 4.6
parent cbb8f93435
commit ab97af0ea3
4 changed files with 558 additions and 120 deletions
+22 -7
View File
@@ -383,13 +383,14 @@ CREATE TABLE IF NOT EXISTS pf_lists (
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0); INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items ( CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1, list_id INT NOT NULL DEFAULT 1,
label VARCHAR(500) NOT NULL, category_id INT DEFAULT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0, label VARCHAR(500) NOT NULL,
position INT NOT NULL DEFAULT 0, in_cart TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, position INT NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id) KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -402,6 +403,20 @@ CREATE TABLE IF NOT EXISTS pf_grocery_history (
KEY idx_hist_last (last_used_at) KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_list_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
name VARCHAR(100) NOT NULL,
position INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL,
category_id INT NOT NULL,
UNIQUE KEY uq_keyword (keyword)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendar iOS ───────────────────────────────────────────────────────────── -- ─── Calendar iOS ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events ( CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
+309 -98
View File
@@ -6,56 +6,62 @@ require_login();
header('Content-Type: application/json'); header('Content-Type: application/json');
set_exception_handler(function (\Throwable $e) { set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) { if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
header('Content-Type: application/json'); echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
http_response_code(500);
}
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
exit;
}); });
require_once dirname(__DIR__, 2) . '/includes/db.php'; require_once dirname(__DIR__, 2) . '/includes/db.php';
// Auto-create tables // ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY, list_id INT NOT NULL DEFAULT 1,
list_id INT NOT NULL DEFAULT 1, category_id INT DEFAULT NULL, label VARCHAR(500) NOT NULL,
label VARCHAR(500) NOT NULL, in_cart TINYINT(1) NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id) KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY, label_hash CHAR(64) NOT NULL,
label_hash CHAR(64) NOT NULL,
label_display VARCHAR(500) NOT NULL, label_display VARCHAR(500) NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_grocery_hist_hash (label_hash), UNIQUE KEY uq_grocery_hist_hash (label_hash), KEY idx_hist_last (last_used_at)
KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Add list_id column if missing (migration from old groceries table) $pdo->exec("CREATE TABLE IF NOT EXISTS pf_list_categories (
id INT AUTO_INCREMENT PRIMARY KEY, icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
name VARCHAR(100) NOT NULL, position INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
id INT AUTO_INCREMENT PRIMARY KEY, keyword VARCHAR(255) NOT NULL,
category_id INT NOT NULL, UNIQUE KEY uq_keyword (keyword)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Migrations
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
$action = $_GET['action'] ?? $_POST['action'] ?? ''; // ── Seed categories if empty ──────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD']; if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
$cats = [
$body = []; ['🍎','Fruits & Légumes'],['🍖','Viande'],['🐟','Poissonnerie'],
if ($method === 'PUT' || $method === 'POST') { ['🍞','Boulangerie'],['🥛','Frais'],['❄️','Surgelés'],
$raw = file_get_contents('php://input'); ['🧃','Boissons'],['🌾','Pâtes, Riz, Féculents'],['🥜','Épicerie salée'],
if ($raw) $body = json_decode($raw, true) ?? []; ['🥫','Conserves'],['🍛','Plats cuisinés'],['🧂','Sauces & Condiments'],
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v; ['🥐','Petit déjeuner'],['🍪','Biscuits & Gâteaux'],['🍫','Confiserie'],
['🍦','Dessert'],['🧴','Beauté & Hygiène'],['🍼','Bébé'],
['🧼','Entretien'],['🐶','Animaux'],['🏡','Maison & Jardin'],['💊','Pharmacie'],
];
$ins = $pdo->prepare("INSERT INTO pf_list_categories (icon, name, position) VALUES (?,?,?)");
foreach ($cats as $i => $c) $ins->execute([$c[0], $c[1], $i * 10]);
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -64,25 +70,246 @@ function liste_normalize(string $s): string {
return mb_strtolower(trim(mb_substr($s, 0, 500))); return mb_strtolower(trim(mb_substr($s, 0, 500)));
} }
function liste_normalize_detect(string $s): string {
$s = mb_strtolower(trim($s));
$from = ['é','è','ê','ë','à','â','ä','ù','û','ü','î','ï','ô','ö','ç','œ','æ'];
$to = ['e','e','e','e','a','a','a','u','u','u','i','i','o','o','c','oe','ae'];
$s = str_replace($from, $to, $s);
return trim(preg_replace('/[^a-z0-9 ]/', '', $s));
}
function liste_touch_history(PDO $pdo, string $label): void { function liste_touch_history(PDO $pdo, string $label): void {
$hash = hash('sha256', liste_normalize($label)); $hash = hash('sha256', liste_normalize($label));
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) $pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()") ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
->execute([$hash, trim($label)]); ->execute([$hash, trim($label)]);
} }
function liste_ensure_default(PDO $pdo): int { function liste_ensure_default(PDO $pdo): int {
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn(); if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() === 0) {
if ($count === 0) {
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)"); $pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
return (int) $pdo->lastInsertId(); return (int)$pdo->lastInsertId();
} }
return (int) $pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn(); return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
}
// Built-in keyword → category position mapping (position maps to seeded order above)
function liste_builtin_detect(string $normalized): ?int {
static $map = null;
if ($map === null) $map = [
// 1 = Fruits & Légumes
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
'tomate'=>1,'concombre'=>1,'carotte'=>1,'oignon'=>1,'poireau'=>1,
'courgette'=>1,'aubergine'=>1,'poivron'=>1,'salade'=>1,'laitue'=>1,
'epinard'=>1,'brocoli'=>1,'chou'=>1,'choux'=>1,'haricot'=>1,'pois'=>1,
'champignon'=>1,'ail'=>1,'echalote'=>1,'pomme de terre'=>1,'patate'=>1,
'navet'=>1,'radis'=>1,'betterave'=>1,'fenouil'=>1,'asperge'=>1,
'artichaut'=>1,'celeri'=>1,'butternut'=>1,'courge'=>1,'potiron'=>1,
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
// 2 = Viande
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
// 3 = Poissonnerie
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
// 4 = Boulangerie
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
// 5 = Frais
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
// 6 = Surgelés
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
// 7 = Boissons
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
// 8 = Pâtes, Riz, Féculents
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
// 9 = Épicerie salée
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
// 10 = Conserves
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
'soupe boite'=>10,'haricot boite'=>10,
// 11 = Plats cuisinés
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
// 12 = Sauces & Condiments
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
// 13 = Petit déjeuner
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
// 14 = Biscuits & Gâteaux
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
'palmier'=>14,'macaron'=>14,'eclair'=>14,
// 15 = Confiserie
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
// 16 = Dessert
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
// 17 = Beauté & Hygiène
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
'hydratant'=>17,'demaquillant'=>17,
// 18 = Bébé
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
'creme bebe'=>18,'sucette bebe'=>18,
// 19 = Entretien
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
// 20 = Animaux
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
// 21 = Maison & Jardin
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
// 22 = Pharmacie
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
];
// Check full phrase
if (isset($map[$normalized])) return $map[$normalized];
// Check individual words
$words = explode(' ', $normalized);
foreach ($words as $w) {
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
}
// Partial match (contains)
foreach ($map as $kw => $catPos) {
if (str_contains($normalized, $kw)) return $catPos;
}
return null;
}
function liste_detect_category(PDO $pdo, string $label): ?int {
$norm = liste_normalize_detect($label);
// 1. Learned rules (exact normalized label)
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$norm]);
if ($row = $r->fetch()) return (int)$row['category_id'];
// 2. Learned rules (individual words)
foreach (explode(' ', $norm) as $w) {
if (strlen($w) < 3) continue;
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
$r->execute([$w]);
if ($row = $r->fetch()) return (int)$row['category_id'];
}
// 3. Built-in dictionary → map position to real category id
$pos = liste_builtin_detect($norm);
if ($pos === null) return null;
// Get category id by position order
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
return $ids[$pos - 1] ?? null;
}
function liste_learn(PDO $pdo, string $label, int $category_id): void {
$norm = liste_normalize_detect($label);
if (!$norm) return;
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
->execute([$norm, $category_id]);
// Also learn individual meaningful words
foreach (explode(' ', $norm) as $w) {
if (strlen($w) >= 4) {
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
->execute([$w, $category_id]);
}
}
}
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
$body = [];
if ($method === 'PUT' || $method === 'POST') {
$raw = file_get_contents('php://input');
if ($raw) $body = json_decode($raw, true) ?? [];
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
}
// ── CATEGORIES ────────────────────────────────────────────────────────────────
if ($action === 'categories' && $method === 'GET') {
$rows = $pdo->query("SELECT id, icon, name, position FROM pf_list_categories ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['categories' => $rows]);
exit;
}
// ── SET CATEGORY (+ learn) ────────────────────────────────────────────────────
if ($action === 'set_category' && $method === 'POST') {
$item_id = (int)($body['item_id'] ?? 0);
$cat_id_raw = $body['category_id'] ?? null;
$category_id = ($cat_id_raw === null || $cat_id_raw === '' || $cat_id_raw === '0') ? null : (int)$cat_id_raw;
if (!$item_id) { http_response_code(400); echo json_encode(['error' => 'item_id required']); exit; }
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
// Learn from manual assignment
if ($category_id !== null) {
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
$row->execute([$item_id]);
if ($r = $row->fetch()) liste_learn($pdo, $r['label'], $category_id);
}
echo json_encode(['ok' => true]);
exit;
} }
// ── LISTS ───────────────────────────────────────────────────────────────────── // ── LISTS ─────────────────────────────────────────────────────────────────────
if ($action === 'lists') { if ($action === 'lists') {
if ($method === 'GET') { if ($method === 'GET') {
$firstId = liste_ensure_default($pdo); $firstId = liste_ensure_default($pdo);
@@ -91,125 +318,109 @@ if ($action === 'lists') {
exit; exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
$name = trim($body['name'] ?? ''); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$name = mb_substr($name, 0, 100); $pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$s = $pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists"); $pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]);
$pos = (int) $s->fetchColumn(); echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]);
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?, ?)")->execute([$name, $pos]);
$id = (int) $pdo->lastInsertId();
echo json_encode(['id' => $id, 'name' => $name, 'position' => $pos]);
exit; exit;
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = (int) ($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$name = trim($body['name'] ?? ''); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
if (!$id || $name === '') { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; } if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([mb_substr($name,0,100), $id]); $pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]); exit;
exit;
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn(); if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() <= 1) {
if ($count <= 1) { http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit; } http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit;
}
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]); $pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]); $pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]); exit;
exit;
} }
} }
// ── ITEMS ───────────────────────────────────────────────────────────────────── // ── ITEMS ─────────────────────────────────────────────────────────────────────
if ($action === 'items') { if ($action === 'items') {
$list_id = (int) ($_GET['list_id'] ?? $body['list_id'] ?? 0); $list_id = (int)($_GET['list_id'] ?? $body['list_id'] ?? 0);
if ($method === 'GET') { if ($method === 'GET') {
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; } if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
$s = $pdo->prepare("SELECT id, list_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id"); $s = $pdo->prepare("SELECT id, list_id, category_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
$s->execute([$list_id]); $s->execute([$list_id]);
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); exit;
exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
$label = trim($body['label'] ?? ''); $label = trim($body['label'] ?? '');
if (!$list_id || $label === '') { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; } if (!$list_id || !$label) { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
$norm = liste_normalize($label); $norm = liste_normalize($label);
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?"); $dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
$dup->execute([$list_id, $norm]); $dup->execute([$list_id, $norm]);
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; } if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?"); $q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
$q->execute([$list_id]); $q->execute([$list_id]);
$pos = (int) $q->fetchColumn(); $pos = (int)$q->fetchColumn();
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, label, in_cart, position) VALUES (?,?,0,?)")->execute([$list_id, $label, $pos]); $category_id = liste_detect_category($pdo, $label);
$id = (int) $pdo->lastInsertId(); $pdo->prepare("INSERT INTO pf_grocery_items (list_id, category_id, label, in_cart, position) VALUES (?,?,?,0,?)")->execute([$list_id, $category_id, $label, $pos]);
$id = (int)$pdo->lastInsertId();
liste_touch_history($pdo, $label); liste_touch_history($pdo, $label);
echo json_encode(['id' => $id, 'list_id' => $list_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); echo json_encode(['id' => $id, 'list_id' => $list_id, 'category_id' => $category_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); exit;
exit;
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = (int) ($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; } if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
if (isset($body['in_cart'])) { if (isset($body['in_cart'])) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
$pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
}
if (isset($body['label'])) { if (isset($body['label'])) {
$label = trim($body['label']); $label = trim($body['label']);
if ($label !== '') { $pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]); liste_touch_history($pdo, $label); } if ($label) {
$pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]);
liste_touch_history($pdo, $label);
}
} }
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]); exit;
exit;
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; } if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); $r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); $r->execute([$id]);
$r->execute([$id]);
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']); if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]); $pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]); exit;
exit;
} }
} }
// ── BULK ────────────────────────────────────────────────────────────────────── // ── BULK ──────────────────────────────────────────────────────────────────────
if ($action === 'uncheck_all' && $method === 'POST') { if ($action === 'uncheck_all' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0); $lid = (int)($body['list_id'] ?? 0);
if ($list_id) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$list_id]); if ($lid) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$lid]);
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if ($action === 'delete_picked' && $method === 'POST') { if ($action === 'delete_picked' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0); $lid = (int)($body['list_id'] ?? 0);
if ($list_id) { if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows->execute([$lid]);
$rows->execute([$list_id]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']); foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$list_id]); $pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$lid]);
} }
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if ($action === 'clear_all' && $method === 'POST') { if ($action === 'clear_all' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0); $lid = (int)($body['list_id'] ?? 0);
if ($list_id) { if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows->execute([$lid]);
$rows->execute([$list_id]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']); foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$list_id]); $pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$lid]);
} }
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
// ── HISTORY ─────────────────────────────────────────────────────────────────── // ── HISTORY ───────────────────────────────────────────────────────────────────
if ($action === 'history' && $method === 'GET') { if ($action === 'history' && $method === 'GET') {
$max = 20; $max = 20;
$note = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'"); $n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'"); $n->execute();
$note->execute(); if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
if ($n = $note->fetch()) $max = max(1, min(50, (int)$n['content']));
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max"); $s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit; echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
} }
+112
View File
@@ -310,6 +310,110 @@
} }
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; } .liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header {
font-size: .75rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .05em;
padding: .6rem .25rem .2rem;
display: flex;
align-items: center;
gap: .4rem;
}
.liste-cat-section-header:first-child { padding-top: .1rem; }
.liste-cat-section-count {
background: var(--muted-bg, #e9ecef);
color: var(--muted, #6c757d);
border-radius: 999px;
font-size: .7rem;
padding: .05rem .4rem;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
/* ── Category badge (per-item) ─────────────────────────────────────────────── */
.liste-cat-badge {
flex-shrink: 0;
background: none;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px;
padding: .1rem .3rem;
cursor: pointer;
font-size: .85rem;
line-height: 1.4;
color: inherit;
transition: border-color .15s, background .15s;
}
.liste-cat-badge:hover {
border-color: var(--primary, #4361ee);
background: var(--primary-light, #eef0fd);
}
.liste-cat-badge-empty {
opacity: .35;
border-style: dashed;
}
.liste-row:hover .liste-cat-badge-empty { opacity: .7; }
/* ── Category picker popover ───────────────────────────────────────────────── */
.liste-cat-picker {
position: fixed;
z-index: 9999;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.15);
width: 216px;
overflow: hidden;
}
.liste-cat-picker-title {
font-size: .72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
padding: .6rem .75rem .35rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-cat-picker-list {
overflow-y: auto;
max-height: 300px;
padding: .3rem;
}
.liste-cat-picker-item {
display: flex;
align-items: center;
gap: .4rem;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 6px;
padding: .4rem .6rem;
font-size: .83rem;
color: var(--text, #212529);
cursor: pointer;
transition: background .1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.liste-cat-picker-item:hover { background: var(--hover-bg, #f8f9fa); }
.liste-cat-picker-item.active {
background: var(--primary-light, #eef0fd);
color: var(--primary, #4361ee);
font-weight: 600;
}
/* ── Dark mode ─────────────────────────────────────────────────────────────── */ /* ── Dark mode ─────────────────────────────────────────────────────────────── */
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; } [data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
@@ -325,3 +429,11 @@
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; } [data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; } [data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; } [data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
[data-theme='dark'] .liste-cat-picker { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-title { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-picker-item { color: #cdd6f4; }
[data-theme='dark'] .liste-cat-picker-item:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-cat-picker-item.active { background: #313155; color: #89b4fa; }
+115 -15
View File
@@ -1,4 +1,4 @@
/* invraw — Liste module JS */ /* Liste module JS */
const API = '/modules/liste/api.php'; const API = '/modules/liste/api.php';
const T = window.LISTE_TRANSLATIONS || {}; const T = window.LISTE_TRANSLATIONS || {};
@@ -7,9 +7,11 @@ const state = {
currentListId: null, currentListId: null,
items: [], items: [],
history: [], history: [],
categories: [],
catMap: {},
}; };
// ── Utilities ───────────────────────────────────────────────────────────────── // ── Utilities ─────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -40,7 +42,67 @@ async function api(action, method = 'GET', body = null, params = {}) {
return r.json(); return r.json();
} }
// ── Tabs ────────────────────────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────────────────
async function loadCategories() {
if (state.categories.length) return;
const r = await api('categories');
state.categories = r.categories || [];
state.catMap = {};
state.categories.forEach(c => { state.catMap[c.id] = c; });
}
let activePicker = null;
function openCategoryPicker(itemId, anchorEl) {
closeCategoryPicker();
const item = state.items.find(i => i.id === itemId);
if (!item) return;
const picker = document.createElement('div');
picker.className = 'liste-cat-picker';
picker.innerHTML = `
<div class="liste-cat-picker-title">Catégorie</div>
<div class="liste-cat-picker-list">
<button class="liste-cat-picker-item${!item.category_id ? ' active' : ''}" data-cat="0">🏷️ Non classé</button>
${state.categories.map(c =>
`<button class="liste-cat-picker-item${item.category_id == c.id ? ' active' : ''}" data-cat="${c.id}">${esc(c.icon)} ${esc(c.name)}</button>`
).join('')}
</div>`;
const rect = anchorEl.getBoundingClientRect();
let top = rect.bottom + 4;
let left = rect.left;
if (left + 220 > window.innerWidth) left = window.innerWidth - 228;
if (top + 340 > window.innerHeight) top = rect.top - Math.min(340, top + 340 - window.innerHeight) - 4;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
document.body.appendChild(picker);
activePicker = picker;
picker.querySelectorAll('.liste-cat-picker-item').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const catId = parseInt(btn.dataset.cat) || null;
await api('set_category', 'POST', { item_id: itemId, category_id: catId });
const it = state.items.find(i => i.id === itemId);
if (it) it.category_id = catId;
closeCategoryPicker();
renderItems();
});
});
setTimeout(() => {
document.addEventListener('click', closeCategoryPicker, { once: true });
}, 0);
}
function closeCategoryPicker() {
if (activePicker) { activePicker.remove(); activePicker = null; }
}
// ── Tabs ───────────────────────────────────────────────────────────────────────
function renderTabs() { function renderTabs() {
const container = document.getElementById('liste-tabs'); const container = document.getElementById('liste-tabs');
@@ -64,11 +126,9 @@ function renderTabs() {
container.appendChild(tab); container.appendChild(tab);
}); });
// Rename button
container.querySelectorAll('.liste-tab-rename').forEach(btn => { container.querySelectorAll('.liste-tab-rename').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); }); btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
}); });
// Delete button
container.querySelectorAll('.liste-tab-delete').forEach(btn => { container.querySelectorAll('.liste-tab-delete').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); }); btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
}); });
@@ -101,7 +161,7 @@ async function deleteList(listId) {
toast(T.list_deleted || 'Liste supprimée'); toast(T.list_deleted || 'Liste supprimée');
} }
// ── List management ──────────────────────────────────────────────────────────── // ── List management ────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => { document.getElementById('btn-add-list')?.addEventListener('click', () => {
document.getElementById('liste-new-form')?.classList.remove('hidden'); document.getElementById('liste-new-form')?.classList.remove('hidden');
@@ -136,7 +196,7 @@ async function confirmNewList() {
} }
} }
// ── Switch list ──────────────────────────────────────────────────────────────── // ── Switch list ────────────────────────────────────────────────────────────────
async function switchList(listId) { async function switchList(listId) {
state.currentListId = listId; state.currentListId = listId;
@@ -144,7 +204,7 @@ async function switchList(listId) {
renderTabs(); renderTabs();
} }
// ── Items ────────────────────────────────────────────────────────────────────── // ── Items ──────────────────────────────────────────────────────────────────────
async function loadItems() { async function loadItems() {
if (!state.currentListId) { if (!state.currentListId) {
@@ -168,12 +228,44 @@ function renderItems() {
return; return;
} }
if (toolbar) toolbar.style.display = ''; if (toolbar) toolbar.style.display = '';
const pending = state.items.filter(i => !i.in_cart); const pending = state.items.filter(i => !i.in_cart);
const inCart = state.items.filter(i => i.in_cart); const inCart = state.items.filter(i => i.in_cart);
const hasCategories = pending.some(i => i.category_id);
let html = ''; let html = '';
if (pending.length) html += pending.map(rowHtml).join('');
if (inCart.length) html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join(''); if (hasCategories && state.categories.length) {
// Group pending items by category
const groups = new Map();
pending.forEach(item => {
const key = item.category_id ?? 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
});
// Render in category order, uncategorized last
const catIds = state.categories.map(c => parseInt(c.id));
const orderedKeys = catIds.filter(id => groups.has(id));
if (groups.has(0)) orderedKeys.push(0);
orderedKeys.forEach(key => {
const items = groups.get(key);
const cat = key ? state.catMap[key] : null;
const label = cat ? `${cat.icon} ${cat.name}` : '🏷️ Non classé';
html += `<div class="liste-cat-section-header">${esc(label)} <span class="liste-cat-section-count">${items.length}</span></div>`;
html += items.map(rowHtml).join('');
});
} else {
html = pending.map(rowHtml).join('');
}
if (inCart.length) {
html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
}
root.innerHTML = html; root.innerHTML = html;
root.querySelectorAll('.liste-check').forEach(cb => { root.querySelectorAll('.liste-check').forEach(cb => {
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0)); cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
}); });
@@ -183,12 +275,20 @@ function renderItems() {
root.querySelectorAll('.btn-delete-item').forEach(btn => { root.querySelectorAll('.btn-delete-item').forEach(btn => {
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id))); btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
}); });
root.querySelectorAll('.liste-cat-badge').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openCategoryPicker(parseInt(btn.dataset.id), btn); });
});
} }
function rowHtml(item) { function rowHtml(item) {
const checked = item.in_cart ? 'checked' : ''; const checked = item.in_cart ? 'checked' : '';
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row'; const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
const cat = item.category_id ? state.catMap[item.category_id] : null;
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
const badgeIcon = cat ? esc(cat.icon) : '🏷️';
const badgeTip = cat ? esc(cat.name) : 'Non classé';
return `<div class="${cls}" data-id="${item.id}"> return `<div class="${cls}" data-id="${item.id}">
<button class="${badgeCls}" data-id="${item.id}" title="${badgeTip}">${badgeIcon}</button>
<label class="liste-check-label"> <label class="liste-check-label">
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}> <input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
<span class="liste-label">${esc(item.label)}</span> <span class="liste-label">${esc(item.label)}</span>
@@ -261,7 +361,7 @@ document.getElementById('btn-clear-all')?.addEventListener('click', async () =>
toast(T.deleted || 'Liste vidée'); toast(T.deleted || 'Liste vidée');
}); });
// ── Add input ────────────────────────────────────────────────────────────────── // ── Add input ──────────────────────────────────────────────────────────────────
document.getElementById('btn-liste-add')?.addEventListener('click', () => { document.getElementById('btn-liste-add')?.addEventListener('click', () => {
const input = document.getElementById('liste-input'); const input = document.getElementById('liste-input');
@@ -301,12 +401,12 @@ function renderHistory() {
}); });
} }
// ── Init ─────────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────────
async function init() { async function init() {
const r = await api('lists'); const [listsR] = await Promise.all([api('lists'), loadCategories()]);
state.lists = r.lists || []; state.lists = listsR.lists || [];
state.currentListId = r.default_id || state.lists[0]?.id || null; state.currentListId = listsR.default_id || state.lists[0]?.id || null;
renderTabs(); renderTabs();
await Promise.all([loadItems(), loadHistory()]); await Promise.all([loadItems(), loadHistory()]);
} }