Create food planning & merge liste into food
Deploy HouseHub / deploy (push) Successful in 2s

This commit is contained in:
FeFeClochette committed 2026-09-15 10:58:23 +02:00
1 parent 3c615e3021
commit 4cfef07dc6
22 files changed
+2918 -780

No files matched your search

File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
<?php
// modules/food/includes/api/api-liste.php
ob_start();
require_once dirname(__DIR__, 4) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
});
require_once dirname(__DIR__, 4) . '/includes/db.php';
// ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL, list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, list_id INT NOT NULL DEFAULT 1,
category_id INT DEFAULT NULL, label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
id INT AUTO_INCREMENT PRIMARY KEY, label_hash CHAR(64) NOT NULL,
label_display VARCHAR(500) NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_grocery_hist_hash (label_hash), KEY idx_hist_last (last_used_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$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 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_lists ADD COLUMN color VARCHAR(20) DEFAULT NULL AFTER name"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN list_type VARCHAR(50) DEFAULT NULL AFTER color"); } catch (\Exception $e) {}
// ── Seed categories if empty ──────────────────────────────────────────────────
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
$cats = [
['🍎','Fruits & Légumes'],['🍖','Viande'],['🐟','Poissonnerie'],
['🍞','Boulangerie'],['🥛','Frais'],['❄️','Surgelés'],
['🧃','Boissons'],['🌾','Pâtes, Riz, Féculents'],['🥜','Épicerie salée'],
['🥫','Conserves'],['🍛','Plats cuisinés'],['🧂','Sauces & Condiments'],
['🥐','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 ───────────────────────────────────────────────────────────────────
function liste_normalize(string $s): string {
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 {
$hash = hash('sha256', liste_normalize($label));
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
->execute([$hash, trim($label)]);
}
function liste_ensure_default(PDO $pdo): int {
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() === 0) {
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
return (int)$pdo->lastInsertId();
}
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
}
function liste_builtin_detect(string $normalized): ?int {
static $map = null;
if ($map === null) $map = [
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
'profiterole'=>16,
'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,
'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,
'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,
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
'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,
'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,
'antihistaminique'=>22,'antidouleur'=>22,
];
if (isset($map[$normalized])) return $map[$normalized];
$words = explode(' ', $normalized);
foreach ($words as $w) {
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
}
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);
$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'];
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'];
}
$pos = liste_builtin_detect($norm);
if ($pos === null) return null;
$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]);
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]);
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 ─────────────────────────────────────────────────────────────────────
if ($action === 'lists') {
if ($method === 'GET') {
$firstId = liste_ensure_default($pdo);
$rows = $pdo->query("SELECT id, name, color, list_type, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
exit;
}
if ($method === 'POST') {
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null;
$list_type = mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null;
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$pdo->prepare("INSERT INTO pf_lists (name, color, list_type, position) VALUES (?,?,?,?)")->execute([$name, $color, $list_type, $pos]);
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]);
exit;
}
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = array_key_exists('color', $body) ? (mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null) : false;
$list_type = array_key_exists('list_type', $body) ? (mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null) : false;
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$sets = ['name=?'];
$vals = [$name];
if ($color !== false) { $sets[] = 'color=?'; $vals[] = $color; }
if ($list_type !== false) { $sets[] = 'list_type=?'; $vals[] = $list_type; }
$vals[] = $id;
$pdo->prepare("UPDATE pf_lists SET " . implode(', ', $sets) . " WHERE id=?")->execute($vals);
echo json_encode(['ok' => true]); exit;
}
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() <= 1) {
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_lists WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); exit;
}
}
// ── ITEMS ─────────────────────────────────────────────────────────────────────
if ($action === 'items') {
$list_id = (int)($_GET['list_id'] ?? $body['list_id'] ?? 0);
if ($method === 'GET') {
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
$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]);
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); exit;
}
if ($method === 'POST') {
$label = trim($body['label'] ?? '');
if (!$list_id || !$label) { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
$norm = liste_normalize($label);
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
$dup->execute([$list_id, $norm]);
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->execute([$list_id]);
$pos = (int)$q->fetchColumn();
$category_id = liste_detect_category($pdo, $label);
$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);
echo json_encode(['id' => $id, 'list_id' => $list_id, 'category_id' => $category_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); exit;
}
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
if (isset($body['in_cart'])) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
if (isset($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);
}
}
echo json_encode(['ok' => true]); exit;
}
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
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->execute([$id]);
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]); exit;
}
}
// ── BULK ──────────────────────────────────────────────────────────────────────
if ($action === 'uncheck_all' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$lid]);
echo json_encode(['ok' => true]); exit;
}
if ($action === 'delete_picked' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows->execute([$lid]);
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([$lid]);
}
echo json_encode(['ok' => true]); exit;
}
if ($action === 'clear_all' && $method === 'POST') {
$lid = (int)($body['list_id'] ?? 0);
if ($lid) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows->execute([$lid]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$lid]);
}
echo json_encode(['ok' => true]); exit;
}
// ── HISTORY ───────────────────────────────────────────────────────────────────
if ($action === 'history' && $method === 'GET') {
$max = 20;
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'"); $n->execute();
if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
$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;
}
http_response_code(400);
echo json_encode(['error' => 'unknown action: ' . $action]);
+73
View File
@@ -0,0 +1,73 @@
<?php
// Désactive l'affichage des erreurs HTML pour garantir un retour JSON strict
ini_set('display_errors', 0);
error_reporting(0);
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require dirname(__DIR__, 4) . '/includes/i18n.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
try {
$dateParam = $_GET['date'] ?? date('Y-m-d');
$baseDate = new DateTime($dateParam);
// Aligner sur le lundi de la semaine
$monday = clone $baseDate;
if ($monday->format('N') != 1) {
$monday->modify('last monday');
}
$sunday = clone $monday;
$sunday->modify('+6 days');
$weekData = [
'week_label' => tr('meal_week') . ' ' . $monday->format('W'),
'days' => []
];
// Extraction des repas de la base (person_id = 0 pour le foyer global)
$stmt = $pdo->prepare("SELECT plan_date, service, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ? AND person_id = 0");
$stmt->execute([$monday->format('Y-m-d'), $sunday->format('Y-m-d')]);
$meals = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$meals[$row['plan_date']][$row['service']] = $row['meal_name'];
}
$current = clone $monday;
// Mappage vers les clés i18n existantes de HouseHub
$daysMap = [
1 => 'day_mon', 2 => 'day_tue', 3 => 'day_wed',
4 => 'day_thu', 5 => 'day_fri', 6 => 'day_sat', 7 => 'day_sun'
];
for ($i = 0; $i < 7; $i++) {
$dStr = $current->format('Y-m-d');
$dayNum = $current->format('N'); // 1 à 7
$monthNum = $current->format('m'); // 01 à 12
$dayDate = $current->format('d'); // 01 à 31
$dayName = tr($daysMap[$dayNum]);
$monthName = tr('month_' . $monthNum);
$weekData['days'][] = [
'date' => $dStr,
'day_name' => $dayName,
'date_formatted' => $dayDate . ' ' . $monthName,
'lunch' => $meals[$dStr]['lunch'] ?? '',
'dinner' => $meals[$dStr]['dinner'] ?? ''
];
$current->modify('+1 day');
}
echo json_encode(['success' => true, 'data' => $weekData], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+277
View File
@@ -0,0 +1,277 @@
<?php
// modules/meals/includes/api/import-pdf.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['pdf_file'])) {
echo json_encode(['success' => false, 'error' => 'Aucun fichier reçu.']);
exit;
}
$personId = (int)$_POST['person_id'];
if ($personId <= 0) {
echo json_encode(['success' => false, 'error' => 'Enfant non spécifié.']);
exit;
}
// ---------------------------------------------------------
// PARSEUR PDF SPATIAL NATIF (100% PHP SANS LIBRAIRIE)
// ---------------------------------------------------------
class NativePdfParser {
public static function getMealData($filename) {
$content = file_get_contents($filename);
$texts = [];
// 1. Décompression et Tokenization Mathématique
if (preg_match_all('/stream(.*?)endstream/is', $content, $matches)) {
foreach ($matches[1] as $stream) {
$stream = ltrim($stream, "\r\n");
$decoded = @gzuncompress($stream);
if (!$decoded) $decoded = $stream;
// On éclate le flux par opérateurs PDF clés (Position et Texte)
$parts = preg_split('/(Tm|Td|Tj|TJ)/', $decoded, -1, PREG_SPLIT_DELIM_CAPTURE);
$x = 0; $y = 0;
for ($i = 0; $i < count($parts); $i++) {
$token = $parts[$i];
if ($token === 'Tm' || $token === 'Td') {
$prev = $parts[$i-1];
preg_match_all('/[0-9.-]+/', $prev, $nums);
if (count($nums[0]) >= 2) {
$yRaw = array_pop($nums[0]);
$xRaw = array_pop($nums[0]);
if ($token === 'Tm') {
$x = (float)$xRaw; $y = (float)$yRaw;
} else {
$x += (float)$xRaw; $y += (float)$yRaw;
}
}
} elseif ($token === 'Tj') {
$prev = $parts[$i-1];
if (preg_match('/\((.*?)\)$/', trim($prev), $m)) {
self::addText($texts, $m[1], $x, $y);
}
} elseif ($token === 'TJ') {
$prev = $parts[$i-1];
if (preg_match('/\[(.*?)\]$/', trim($prev), $m)) {
preg_match_all('/\((.*?)\)/', $m[1], $tjs);
$tjX = $x;
foreach ($tjs[1] as $tj) {
self::addText($texts, $tj, $tjX, $y);
$tjX += 10; // Décalage estimé pour les mots suivants du tableau
}
}
}
}
}
}
if (empty($texts)) throw new Exception("Impossible de lire le contenu du PDF. Le fichier est peut-être crypté ou corrompu.");
// 2. Définition des Colonnes Verticles (X) via les En-têtes de Jour
$dayLabels = ['LUNDI', 'MARDI', 'MERCREDI', 'JEUDI', 'VENDREDI'];
$colXs = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, $dayLabels)) {
$colXs[$clean] = $t['x'];
}
}
// Sécurité si les jours exacts ne sont pas détectés à cause de la police
if (count($colXs) < 5) {
$allXs = array_column($texts, 'x');
sort($allXs);
$minX = $allXs[0] ?? 0;
$maxX = end($allXs) ?? 500;
$step = ($maxX - $minX) / 5;
$colXs = [
'LUNDI' => $minX + $step * 0.5,
'MARDI' => $minX + $step * 1.5,
'MERCREDI' => $minX + $step * 2.5,
'JEUDI' => $minX + $step * 3.5,
'VENDREDI' => $minX + $step * 4.5,
];
}
$sortedXs = array_values($colXs);
sort($sortedXs);
// 3. Définition des Tranches Horizontales (Y) via les Titres de Rangée (Zone Extrême Gauche)
$rowHeaders = [];
foreach ($texts as $t) {
$clean = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
if (in_array($clean, ['HORSDEUVRE', 'DECLINAISON', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'DESSERT', 'LESGOUTERS'])) {
// On s'assure de ne capter que la colonne tout à gauche
if ($t['x'] < ($sortedXs[0] - 20)) {
$rowHeaders[] = ['label' => $clean, 'y' => $t['y']];
}
}
}
// Tri de haut en bas (Y du PDF est décroissant du haut vers le bas)
usort($rowHeaders, fn($a, $b) => $b['y'] <=> $a['y']);
$yPlatTop = null; $yPlatBot = null;
$yGarTop = null; $yGarBot = null;
for ($i = 0; $i < count($rowHeaders); $i++) {
if ($rowHeaders[$i]['label'] === 'PLATCHAUD') {
$yPlatTop = $rowHeaders[$i]['y'];
// La limite basse est le label suivant, ou -50 unités
$yPlatBot = $rowHeaders[$i+1]['y'] ?? ($yPlatTop - 50);
}
if ($rowHeaders[$i]['label'] === 'GARNITURE') {
$yGarTop = $rowHeaders[$i]['y'];
$yGarBot = $rowHeaders[$i+1]['y'] ?? ($yGarTop - 50);
}
}
if (!$yPlatTop || !$yGarTop) {
throw new Exception("Structure du menu non reconnue (Mots-clés 'PLAT CHAUD' et 'GARNITURE' introuvables).");
}
// 4. Affectation Chirurgicale par Intersection Grille (X, Y)
$days = [[], [], [], [], []];
foreach ($texts as $t) {
$cleanAlpha = strtoupper(preg_replace('/[^A-Z]/', '', $t['text']));
// Rejet des labels génériques et de la fameuse ligne "Déclinaison"
if (in_array($cleanAlpha, ['DECLINAISON', 'MENUVEGETARIEN', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'HORSDEUVRE', 'LESGOUTERS', 'DESSERT'])) continue;
// Retrait des labels BIO/HVE et mentions superflues
$textVal = trim(preg_replace('/\b(AB|HVE|MSC|Label Rouge)\b/i', '', $t['text']));
$textVal = str_replace(['(plat', 'complet)'], '', $textVal);
$textVal = trim(preg_replace('/[^a-zA-ZÀ-ÿ0-9\s&\-]/u', '', $textVal));
if (strlen($textVal) < 3) continue;
// Filtre Vertical : Est-on dans la tranche Plat Chaud ou Garniture ? (Tolérance +/- 5 unités)
$inPlat = ($t['y'] <= $yPlatTop + 5 && $t['y'] >= $yPlatBot + 5);
$inGar = ($t['y'] <= $yGarTop + 5 && $t['y'] >= $yGarBot + 5);
if ($inPlat || $inGar) {
// Filtre Horizontal : Quel est le jour le plus proche ?
$bestCol = 0;
$minDiff = PHP_FLOAT_MAX;
foreach ($sortedXs as $idx => $cx) {
$diff = abs($t['x'] - $cx);
if ($diff < $minDiff) {
$minDiff = $diff;
$bestCol = $idx;
}
}
$days[$bestCol][] = ['text' => $textVal, 'y' => $t['y'], 'type' => $inPlat ? 'plat' : 'gar'];
}
}
// 5. Concaténation Finale
$finalDays = [];
for ($i = 0; $i < 5; $i++) {
$plats = array_filter($days[$i], fn($item) => $item['type'] === 'plat');
$gars = array_filter($days[$i], fn($item) => $item['type'] === 'gar');
usort($plats, fn($a, $b) => $b['y'] <=> $a['y']);
usort($gars, fn($a, $b) => $b['y'] <=> $a['y']);
$platText = self::concatLines($plats);
$garText = self::concatLines($gars);
$full = trim("$platText + $garText", " +");
$finalDays[] = preg_replace('/\s+/', ' ', $full);
}
return $finalDays;
}
private static function concatLines($items) {
$lines = [];
$currentLine = "";
$lastY = null;
foreach ($items as $item) {
// Si le décalage Y est trop fort, on considère que c'est une nouvelle ligne
if ($lastY !== null && abs($lastY - $item['y']) > 8) {
if ($currentLine) $lines[] = trim($currentLine);
$currentLine = $item['text'];
} else {
$currentLine .= " " . $item['text'];
}
$lastY = $item['y'];
}
if ($currentLine) $lines[] = trim($currentLine);
return implode(' ', array_filter($lines, fn($l) => strlen(trim($l)) > 2));
}
private static function addText(&$texts, $rawText, $x, $y) {
// Décodage de l'octal \123
$m = preg_replace_callback('/\\\\([0-7]{1,3})/', function($cb) { return chr(octdec($cb[1])); }, $rawText);
// Détection de l'UTF-16BE (Le BOM \xFE\xFF indique que le texte est double encodé)
if (str_starts_with($m, "\xFE\xFF")) {
$m = mb_convert_encoding(substr($m, 2), 'UTF-8', 'UTF-16BE');
} else {
// Fallback Latin-1 si pas de BOM
$m = mb_convert_encoding($m, 'UTF-8', 'ISO-8859-1');
}
// Suppression radicale des octets nuls responsables de l'espacement forcé (E s c a l o p e)
$m = str_replace("\0", "", $m);
$m = trim(preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\+&\'’\-]/u', '', $m));
if ($m !== '') {
$texts[] = ['text' => $m, 'x' => $x, 'y' => $y];
}
}
}
try {
$filename = $_FILES['pdf_file']['name'];
// 1. Extraction robuste de la date depuis le nom de fichier (Menus-du-14-au-20-septembre-2026.pdf)
$mondayDate = null;
if (preg_match('/(\d{1,2})[\s-]+au[\s-]+\d{1,2}[\s-]+([a-zèéû]+)[\s-]+(\d{4})/i', $filename, $m)) {
$monthsMap = ['janvier'=>'01','fevrier'=>'02','février'=>'02','mars'=>'03','avril'=>'04','mai'=>'05','juin'=>'06','juillet'=>'07','aout'=>'08','août'=>'08','septembre'=>'09','octobre'=>'10','novembre'=>'11','decembre'=>'12','décembre'=>'12'];
$month = $monthsMap[strtolower($m[2])] ?? '09';
$mondayDate = $m[3] . '-' . $month . '-' . str_pad($m[1], 2, '0', STR_PAD_LEFT);
}
if (!$mondayDate) {
echo json_encode(['success' => false, 'error' => 'Veuillez nommer le fichier "Menus-du-14-au-20-septembre-2026.pdf".']);
exit;
}
// 2. Lancement de l'Analyse Spatiale
$plats = NativePdfParser::getMealData($_FILES['pdf_file']['tmp_name']);
if (empty($plats) || count($plats) !== 5) {
echo json_encode(['success' => false, 'error' => 'Erreur lors du découpage des 5 colonnes.']);
exit;
}
// 3. Insertion en BDD (Lundi au Vendredi)
$pdo->beginTransaction();
$stmt = $pdo->prepare("
INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name)
VALUES (?, 'lunch', ?, ?)
ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)
");
$currentDate = new DateTime($mondayDate);
for ($i = 0; $i < 5; $i++) {
$fullMeal = $plats[$i];
if (!empty($fullMeal) && $fullMeal !== '+') {
$stmt->execute([$currentDate->format('Y-m-d'), $personId, $fullMeal]);
}
$currentDate->modify('+1 day');
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
$action = $_POST['action'] ?? '';
try {
if ($action === 'save_cell') {
$date = $_POST['plan_date'];
$service = $_POST['service'];
$person_id = (int)$_POST['person_id'];
$meal_name = trim($_POST['meal_name']);
if (empty($meal_name)) {
$stmt = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmt->execute([$date, $service, $person_id]);
} else {
$stmt = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
$stmt->execute([$date, $service, $person_id, $meal_name]);
}
echo json_encode(['success' => true]);
exit;
}
if ($action === 'save_bulk') {
$updates = json_decode($_POST['updates'], true);
if (is_array($updates)) {
$pdo->beginTransaction();
$stmtDel = $pdo->prepare("DELETE FROM pf_meals_plan WHERE plan_date=? AND service=? AND person_id=?");
$stmtIns = $pdo->prepare("INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)");
foreach ($updates as $u) {
if (empty(trim($u['meal_name']))) {
$stmtDel->execute([$u['plan_date'], $u['service'], $u['person_id']]);
} else {
$stmtIns->execute([$u['plan_date'], $u['service'], $u['person_id'], trim($u['meal_name'])]);
}
}
$pdo->commit();
}
echo json_encode(['success' => true]);
exit;
}
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+567
View File
@@ -0,0 +1,567 @@
<?php
// modules/food/views/liste.php
if (!isset($pdo)) { exit; }
?>
<div class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500" placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</div>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
// ⚠️ NOUVEAU CHEMIN D'API
const API = '/modules/food/includes/api/api-liste.php';
const T = window.LISTE_TRANSLATIONS || {};
const state = {
lists: [],
currentListId: null,
items: [],
history: [],
categories: [],
catMap: {},
};
// ── Constants ──────────────────────────────────────────────────────────────────
const LIST_COLORS = [
{ id: 'none', hex: null },
{ id: 'blue', hex: '#3b82f6' },
{ id: 'red', hex: '#ef4444' },
{ id: 'amber', hex: '#f59e0b' },
{ id: 'purple', hex: '#8b5cf6' },
{ id: 'pink', hex: '#ec4899' },
{ id: 'emerald', hex: '#10b981' },
{ id: 'orange', hex: '#f97316' },
{ id: 'cyan', hex: '#06b6d4' },
{ id: 'indigo', hex: '#6366f1' },
{ id: 'teal', hex: '#14b8a6' },
{ id: 'gray', hex: '#6b7280' },
];
const LIST_TYPES = [
{ value: '', label: '— Aucun —', emoji: '' },
{ value: 'courses', label: 'Courses', emoji: '🛒' },
{ value: 'todo', label: 'To-do', emoji: '✅' },
{ value: 'voyage', label: 'Voyage', emoji: '✈️' },
{ value: 'travail', label: 'Travail', emoji: '💼' },
{ value: 'maison', label: 'Maison', emoji: '🏠' },
{ value: 'sante', label: 'Santé', emoji: '💊' },
{ value: 'loisirs', label: 'Loisirs', emoji: '🎮' },
{ value: 'autre', label: 'Autre', emoji: '📋' },
];
// ── Utilities ──────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toast(msg, type = 'info') {
let c = document.getElementById('liste-toast-container');
if (!c) {
c = document.createElement('div');
c.id = 'liste-toast-container';
c.className = 'liste-toast-container';
document.body.appendChild(c);
}
const t = document.createElement('div');
t.className = `liste-toast liste-toast-${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.classList.add('show'), 10);
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2500);
}
async function api(action, method = 'GET', body = null, params = {}) {
const url = new URL(API, location.origin);
url.searchParams.set('action', action);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
// ── List modal ─────────────────────────────────────────────────────────────────
let activeModal = null;
function openListModal(listId = null) {
if (activeModal) { activeModal.remove(); activeModal = null; }
const list = listId ? state.lists.find(l => l.id === listId) : null;
const isNew = !listId;
const backdrop = document.createElement('div');
backdrop.className = 'liste-modal-backdrop';
backdrop.innerHTML = `
<div class="liste-modal" role="dialog" aria-modal="true">
<div class="liste-modal-header">
<h3>${isNew ? 'Nouvelle liste' : 'Modifier la liste'}</h3>
<button class="liste-modal-close" aria-label="Fermer">×</button>
</div>
<div class="liste-modal-body">
<div class="liste-form-group">
<label class="liste-form-label">Nom</label>
<input class="liste-modal-name" type="text" maxlength="100"
value="${esc(list?.name ?? '')}" placeholder="Ma liste…" autocomplete="off">
</div>
<div class="liste-form-group">
<label class="liste-form-label">Couleur</label>
<div class="liste-color-picker">
${LIST_COLORS.map(c => {
const isActive = (c.hex === null && !list?.color) || (list?.color === c.hex);
const cls = 'liste-color-swatch' + (c.hex === null ? ' liste-color-swatch-none' : '') + (isActive ? ' active' : '');
const style = c.hex ? `style="--swatch:${c.hex}"` : '';
return `<button class="${cls}" data-color="${c.hex ?? ''}" ${style} title="${c.id}"></button>`;
}).join('')}
</div>
</div>
<div class="liste-form-group">
<label class="liste-form-label">Type de liste</label>
<select class="liste-modal-type">
${LIST_TYPES.map(t =>
`<option value="${esc(t.value)}" ${list?.list_type === t.value ? 'selected' : ''}>${t.emoji ? t.emoji + ' ' : ''}${esc(t.label)}</option>`
).join('')}
</select>
</div>
</div>
<div class="liste-modal-footer">
${!isNew && state.lists.length > 1
? `<button class="btn-tool btn-tool-danger liste-modal-delete">Supprimer</button>`
: ''}
<button class="btn-tool liste-modal-cancel">Annuler</button>
<button class="btn-tool btn-tool-primary liste-modal-save">Enregistrer</button>
</div>
</div>`;
document.body.appendChild(backdrop);
activeModal = backdrop;
const nameInput = backdrop.querySelector('.liste-modal-name');
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
// Color swatches
backdrop.querySelectorAll('.liste-color-swatch').forEach(btn => {
btn.addEventListener('click', () => {
backdrop.querySelectorAll('.liste-color-swatch').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
const close = () => { backdrop.remove(); activeModal = null; };
backdrop.querySelector('.liste-modal-close').addEventListener('click', close);
backdrop.querySelector('.liste-modal-cancel').addEventListener('click', close);
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
backdrop.querySelector('.liste-modal-delete')?.addEventListener('click', () => { close(); deleteList(listId); });
backdrop.querySelector('.liste-modal-save').addEventListener('click', () => saveListModal(backdrop, listId));
nameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') saveListModal(backdrop, listId);
if (e.key === 'Escape') close();
});
}
async function saveListModal(backdrop, listId) {
const name = backdrop.querySelector('.liste-modal-name').value.trim();
if (!name) { backdrop.querySelector('.liste-modal-name').focus(); return; }
const activeSwatch = backdrop.querySelector('.liste-color-swatch.active');
const color = activeSwatch?.dataset.color || null;
const list_type = backdrop.querySelector('.liste-modal-type').value || null;
if (listId) {
const r = await api('lists', 'PUT', { name, color, list_type }, { id: listId });
if (r.ok) {
const list = state.lists.find(l => l.id === listId);
if (list) { list.name = name; list.color = color; list.list_type = list_type; }
backdrop.remove(); activeModal = null;
renderTabs();
toast('Liste mise à jour');
}
} else {
const r = await api('lists', 'POST', { name, color, list_type });
if (r.id) {
state.lists.push({ id: r.id, name, color: r.color, list_type: r.list_type, position: r.position });
state.currentListId = r.id;
backdrop.remove(); activeModal = null;
await loadItems();
renderTabs();
toast('Liste créée : ' + name);
}
}
}
// ── 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 typeEmoji(list_type) {
return LIST_TYPES.find(t => t.value === list_type)?.emoji || '';
}
function renderTabs() {
const container = document.getElementById('liste-tabs');
if (!container) return;
container.innerHTML = '';
state.lists.forEach(list => {
const isActive = list.id === state.currentListId;
const tab = document.createElement('div');
tab.className = 'liste-tab' + (isActive ? ' active' : '');
tab.dataset.id = list.id;
// Apply custom color to entire tab
if (list.color) {
if (isActive) {
tab.style.cssText = `background:${list.color};border-color:${list.color};color:#fff`;
} else {
tab.style.cssText = `border-color:${list.color};color:${list.color}`;
}
}
const emoji = typeEmoji(list.list_type);
const prefix = emoji ? `<span class="liste-tab-type">${emoji}</span>` : '';
if (isActive) {
tab.innerHTML = `
${prefix}
<span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}">✏</button>
`;
} else {
tab.innerHTML = `${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id));
}
container.appendChild(tab);
});
container.querySelectorAll('.liste-tab-edit').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openListModal(parseInt(btn.dataset.id)); });
});
}
// ── List management ─────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null));
async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
const r = await api('lists', 'DELETE', null, { id: listId });
if (r.error) { toast(r.error, 'error'); return; }
state.lists = state.lists.filter(l => l.id !== listId);
if (state.currentListId === listId) {
state.currentListId = state.lists[0]?.id ?? null;
}
await loadItems();
renderTabs();
toast(T.list_deleted || 'Liste supprimée');
}
// ── Switch list ─────────────────────────────────────────────────────────────────
async function switchList(listId) {
state.currentListId = listId;
await loadItems();
renderTabs();
}
// ── Items ───────────────────────────────────────────────────────────────────────
async function loadItems() {
if (!state.currentListId) { renderItems(); return; }
const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || [];
renderItems();
}
function renderItems() {
const root = document.getElementById('liste-items-root');
if (!root) return;
const toolbar = document.getElementById('liste-toolbar');
if (!state.currentListId || state.items.length === 0) {
root.innerHTML = state.currentListId
? `<div class="liste-empty-items"><span>📝</span><p>Liste vide — ajoutez un article ci-dessus.</p></div>`
: '';
if (toolbar) toolbar.style.display = 'none';
return;
}
if (toolbar) toolbar.style.display = '';
const pending = 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 = '';
if (hasCategories && state.categories.length) {
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);
});
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.querySelectorAll('.liste-check').forEach(cb => {
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
});
root.querySelectorAll('.btn-edit-item').forEach(btn => {
btn.addEventListener('click', () => editItem(parseInt(btn.dataset.id)));
});
root.querySelectorAll('.btn-delete-item').forEach(btn => {
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) {
const checked = item.in_cart ? 'checked' : '';
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}">
<button class="${badgeCls}" data-id="${item.id}" title="${badgeTip}">${badgeIcon}</button>
<label class="liste-check-label">
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
<span class="liste-label">${esc(item.label)}</span>
</label>
<div class="liste-row-actions">
<button class="btn-edit-item" data-id="${item.id}" title="Modifier">✏</button>
<button class="btn-delete-item" data-id="${item.id}" title="Supprimer">🗑</button>
</div>
</div>`;
}
async function addItem(label) {
const r = await api('items', 'POST', { list_id: state.currentListId, label });
if (r.duplicate) { toast(T.already_in || 'Déjà dans la liste', 'warn'); return; }
if (r.id) {
state.items.push(r);
renderItems();
renderHistory();
toast(T.added || 'Ajouté');
}
}
async function toggleCart(id, next) {
await api('items', 'PUT', { in_cart: next }, { id });
const item = state.items.find(i => i.id === id);
if (item) item.in_cart = next;
renderItems();
}
async function editItem(id) {
const item = state.items.find(i => i.id === id);
if (!item) return;
const newLabel = prompt(T.edit_placeholder || 'Modifier :', item.label);
if (newLabel === null || newLabel.trim() === '') return;
await api('items', 'PUT', { label: newLabel.trim() }, { id });
item.label = newLabel.trim();
renderItems();
toast(T.updated || 'Modifié');
}
async function deleteItem(id) {
await api('items', 'DELETE', null, { id });
state.items = state.items.filter(i => i.id !== id);
renderItems();
toast(T.deleted || 'Supprimé');
}
// ── Toolbar ────────────────────────────────────────────────────────────────────
document.getElementById('btn-uncheck-all')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('uncheck_all', 'POST', { list_id: state.currentListId });
state.items.forEach(i => i.in_cart = 0);
renderItems();
});
document.getElementById('btn-delete-picked')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('delete_picked', 'POST', { list_id: state.currentListId });
state.items = state.items.filter(i => !i.in_cart);
renderItems();
toast(T.deleted || 'Supprimés');
});
document.getElementById('btn-clear-all')?.addEventListener('click', async () => {
if (!confirm(T.confirm_clear || 'Vider toute la liste ?')) return;
await api('clear_all', 'POST', { list_id: state.currentListId });
state.items = [];
renderItems();
toast(T.deleted || 'Liste vidée');
});
// ── Add input ───────────────────────────────────────────────────────────────────
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
const input = document.getElementById('liste-input');
const v = input?.value.trim();
if (v) { addItem(v); input.value = ''; input.focus(); }
});
document.getElementById('liste-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('btn-liste-add')?.click();
});
// ── History ────────────────────────────────────────────────────────────────────
async function loadHistory() {
const r = await api('history');
state.history = r.history || [];
renderHistory();
}
function renderHistory() {
const root = document.getElementById('liste-history-root');
if (!root || !state.history.length) { if (root) root.innerHTML = ''; return; }
const currentLabels = new Set(state.items.map(i => i.label.toLowerCase().trim()));
const suggestions = state.history.filter(h => !currentLabels.has(h.toLowerCase().trim()));
if (!suggestions.length) { root.innerHTML = ''; return; }
root.innerHTML = `
<div class="liste-history-card">
<div class="liste-history-title">${T.history_title || 'Récemment utilisés'}</div>
<div class="liste-history-chips">
${suggestions.map(h => `<button class="liste-chip" data-label="${esc(h)}">${esc(h)}</button>`).join('')}
</div>
</div>`;
root.querySelectorAll('.liste-chip').forEach(btn => {
btn.addEventListener('click', () => {
if (state.currentListId) addItem(btn.dataset.label);
});
});
}
// ── Init ────────────────────────────────────────────────────────────────────────
async function init() {
const [listsR] = await Promise.all([api('lists'), loadCategories()]);
state.lists = listsR.lists || [];
state.currentListId = listsR.default_id || state.lists[0]?.id || null;
renderTabs();
await Promise.all([loadItems(), loadHistory()]);
}
init();
</script>
+357
View File
@@ -0,0 +1,357 @@
<?php
// modules/meals/main.php
// Récupération stricte des membres du foyer (Exclusion des intervenants et proches)
$stmt = $pdo->query("SELECT id, name FROM pf_people WHERE role NOT IN ('helper', 'nounou', 'proche_adulte', 'proche_enfant', 'relative') AND is_active = 1 ORDER BY id ASC");
$members = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($members)) {
echo "<div class='pf-alert pf-alert--warning'>" . tr('meals_no_members') . "</div>";
return;
}
// Calcul des 14 jours (Lundi semaine courante -> Dimanche semaine prochaine)
$start_date = new DateTime();
$start_date->modify('monday this week');
$dates = [];
for ($i = 0; $i < 14; $i++) {
$d = clone $start_date;
$d->modify("+$i days");
$dates[] = $d->format('Y-m-d');
}
$stmtMeals = $pdo->prepare("SELECT plan_date, service, person_id, meal_name FROM pf_meals_plan WHERE plan_date BETWEEN ? AND ?");
$stmtMeals->execute([$dates[0], end($dates)]);
$mealsData = [];
while ($row = $stmtMeals->fetch(PDO::FETCH_ASSOC)) {
$mealsData[$row['plan_date']][$row['service']][$row['person_id']] = $row['meal_name'];
}
?>
<div class="pf-container meals-container">
<div class="meals-header-bar">
<h1 class="meals-title">
<span class="meals-title-icon">🍽️</span>
<span data-i18n="meals_board_title"><?= tr('meals_board_title') ?></span>
</h1>
<div class="meals-actions">
<!-- NOUVEAU : Bouton Import PDF -->
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.add('open'); document.body.classList.add('no-scroll');">
📄 <?= tr('meal_btn_import_pdf') ?>
</button>
</div>
</div>
<!-- Barre contextuelle (Fixe en bas) -->
<div class="context-toolbar" id="contextToolbar">
<button class="ctx-btn" onmousedown="event.preventDefault(); applyToAllInRow()">➡️ <span data-i18n="btn_apply_all"><?= tr('btn_apply_all') ?></span></button>
<button class="ctx-btn primary" onmousedown="event.preventDefault(); openCopySheetFromCell()">🔄 <span data-i18n="btn_copy_to"><?= tr('btn_copy_to') ?></span></button>
</div>
<div class="meals-wrapper">
<?php
$weeks = array_chunk($dates, 7);
foreach ($weeks as $weekIndex => $weekDates):
$startWeekStr = date('d/m', strtotime($weekDates[0]));
$endWeekStr = date('d/m', strtotime(end($weekDates)));
?>
<h2 class="meals-week-title"><?= tr('meal_week') ?> (<?= $startWeekStr ?> - <?= $endWeekStr ?>)</h2>
<div class="meals-table-container">
<table class="meals-table" id="mealsTable_<?= $weekIndex ?>">
<thead>
<tr>
<th class="col-sticky"></th>
<?php foreach ($members as $m): ?>
<th><?= htmlspecialchars(strtoupper($m['name'])) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($weekDates as $dateStr):
$dayKey = strtolower(date('D', strtotime($dateStr)));
$dayName = tr('day_' . $dayKey);
$dayDate = date('d/m', strtotime($dateStr));
$fullDayName = tr('day_full_' . $dayKey) . ' ' . $dayDate;
?>
<tr class="row-lunch">
<td class="col-sticky">
<span class="day-label"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'lunch', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_lunch') ?>">☀️</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['lunch'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="lunch" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<tr class="row-dinner">
<td class="col-sticky">
<span class="day-label invisible"><?= $dayName ?> <?= $dayDate ?></span>
<div class="service-icon" onmousedown="event.preventDefault(); openCopySheetFromRow('<?= $dateStr ?>', 'dinner', '<?= addslashes($fullDayName) ?>')" title="<?= tr('meals_copy_dinner') ?>">🌙</div>
</td>
<?php foreach ($members as $m):
$val = $mealsData[$dateStr]['dinner'][$m['id']] ?? '';
?>
<td class="meal-input-cell">
<textarea class="meal-input js-meal-input" data-d="<?= $dateStr ?>" data-s="dinner" data-p="<?= $m['id'] ?>" placeholder="-"><?= htmlspecialchars($val) ?></textarea>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Bottom Sheet de Copie (Format Grille Ultra-compacte) -->
<div id="copyModal" class="pf-modal pf-bottom-sheet">
<div class="pf-modal-content pf-bs-content copy-modal-large">
<div class="bs-header">
<h3 class="bs-title">🔄 <span data-i18n="modal_copy_title"><?= tr('modal_copy_title') ?></span></h3>
<button class="bs-close" onclick="closeCopyModal()">&times;</button>
</div>
<div id="copySourceLabel" class="meals-copy-source">Source : ...</div>
<div class="week-section-title" data-i18n="week_current"><?= tr('week_current') ?></div>
<div class="copy-days-grid">
<?php for($i=0; $i<7; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
<div class="week-section-title week-section-mt" data-i18n="week_next"><?= tr('week_next') ?></div>
<div class="copy-days-grid">
<?php for($i=7; $i<14; $i++): $dStr = $dates[$i]; $dName = tr('day_full_' . strtolower(date('D', strtotime($dStr)))) . ' ' . date('d/m', strtotime($dStr)); ?>
<div class="copy-day-card">
<div class="copy-day-name"><?= $dName ?></div>
<div class="copy-day-actions">
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'lunch')" title="<?= tr('meal_lunch') ?>">☀️</button>
<button type="button" class="btn-target-service" onmousedown="event.preventDefault(); executeCopy('<?= $dStr ?>', 'dinner')" title="<?= tr('meal_dinner') ?>">🌙</button>
</div>
</div>
<?php endfor; ?>
</div>
</div>
</div>
<!-- NOUVEAU : Modale d'import PDF Cantine -->
<div id="importMenuModal" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header" style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 15px; margin-bottom: 20px;">
<h3 class="pf-modal-title" style="margin: 0; border: none; padding: 0;">📄 <?= tr('meal_import_title') ?></h3>
<button type="button" class="pf-modal-close" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-muted);" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button>
</div>
<form id="importMenuForm" onsubmit="importPdfMenu(event)" action="/modules/food/includes/api/import-pdf.php" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_import_child') ?></label>
<select name="person_id" class="pf-input" required>
<option value="" disabled selected>-- <?= tr('meal_select_child') ?> --</option>
<?php foreach ($members as $m): ?>
<option value="<?= $m['id'] ?>"><?= htmlspecialchars($m['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('meal_pdf_file') ?></label>
<div class="meal-dropzone" onclick="document.getElementById('canteenPdf').click()">
<span class="meal-dropzone-icon">📥</span>
<span id="pdfFileName"><?= tr('meal_drop_pdf') ?></span>
<input type="file" id="canteenPdf" name="pdf_file" accept=".pdf" style="display: none;" required onchange="document.getElementById('pdfFileName').innerText = this.files[0] ? this.files[0].name : '<?= addslashes(tr('meal_drop_pdf')) ?>';">
</div>
</div>
<div class="modal-footer">
<button type="button" class="pf-btn btn-secondary" onclick="document.getElementById('importMenuModal').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" id="btnImportPdf"><?= tr('btn_import') ?></button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Sauvegarde automatique AJAX à chaque modification
document.querySelectorAll('.js-meal-input').forEach(input => {
input.addEventListener('change', async function() {
const fd = new FormData();
fd.append('action', 'save_cell');
fd.append('plan_date', this.dataset.d);
fd.append('service', this.dataset.s);
fd.append('person_id', this.dataset.p);
fd.append('meal_name', this.value);
try {
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
flashSuccess(this);
} catch(e) {
console.error("Save error", e);
}
});
});
});
// Focus Context Bar
let copyState = { type: null, sourceDate: null, service: null, personIdx: null, sourceElements: [], fullDayName: '' };
document.addEventListener('focusin', (e) => {
if(e.target.classList.contains('js-meal-input')) {
copyState = {
type: 'cell',
sourceDate: e.target.getAttribute('data-d'),
service: e.target.getAttribute('data-s'),
personIdx: e.target.getAttribute('data-p'),
sourceElements: [e.target],
fullDayName: ''
};
document.getElementById('contextToolbar').classList.add('active');
}
});
document.addEventListener('focusout', (e) => {
setTimeout(() => {
if(document.activeElement && !document.activeElement.classList.contains('js-meal-input')) {
document.getElementById('contextToolbar').classList.remove('active');
}
}, 150);
});
// Actions Toolbar
window.applyToAllInRow = async function() {
if(copyState.type !== 'cell' || !copyState.sourceElements[0].value.trim()) return;
const val = copyState.sourceElements[0].value;
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${copyState.sourceDate}"][data-s="${copyState.service}"]`);
let updates = [];
inputs.forEach(inp => {
if(inp !== copyState.sourceElements[0]) {
inp.value = val;
flashSuccess(inp);
updates.push({ plan_date: inp.dataset.d, service: inp.dataset.s, person_id: inp.dataset.p, meal_name: val });
}
});
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/food/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
window.openCopySheetFromCell = function() {
if(!copyState.sourceElements[0] || !copyState.sourceElements[0].value.trim()) return;
if(document.activeElement) document.activeElement.blur();
const svcLabel = copyState.service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
const dObj = new Date(copyState.sourceDate);
const dayKey = dObj.toLocaleDateString('en-US', {weekday: 'short'}).toLowerCase();
const dayName = window.I18N['day_full_' + dayKey] || dObj.toLocaleDateString();
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_individual'] || 'Individuel'} (${dayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.openCopySheetFromRow = function(dateStr, service, fullDayName) {
const inputs = document.querySelectorAll(`.js-meal-input[data-d="${dateStr}"][data-s="${service}"]`);
if (!Array.from(inputs).some(inp => inp.value.trim() !== '')) return;
copyState = { type: 'row', sourceDate: dateStr, service: service, sourceElements: Array.from(inputs), fullDayName: fullDayName };
const svcLabel = service === 'lunch' ? (window.I18N['meal_lunch'] || 'Midi') : (window.I18N['meal_dinner'] || 'Soir');
document.getElementById('copySourceLabel').innerText = `Source : ${window.I18N['source_row'] || 'Repas commun'} (${fullDayName} ${svcLabel})`;
document.getElementById('copyModal').classList.add('open');
document.body.classList.add('no-scroll');
};
window.closeCopyModal = function() {
document.getElementById('copyModal').classList.remove('open');
document.body.classList.remove('no-scroll');
};
window.executeCopy = async function(targetDate, targetService) {
let updates = [];
if(copyState.type === 'cell') {
const val = copyState.sourceElements[0].value;
const targetInput = document.querySelector(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"][data-p="${copyState.personIdx}"]`);
if(targetInput) {
targetInput.value = val;
flashSuccess(targetInput);
updates.push({ plan_date: targetDate, service: targetService, person_id: copyState.personIdx, meal_name: val });
}
} else if (copyState.type === 'row') {
const targetInputs = document.querySelectorAll(`.js-meal-input[data-d="${targetDate}"][data-s="${targetService}"]`);
copyState.sourceElements.forEach((inp, idx) => {
if(inp.value.trim() !== '') {
targetInputs[idx].value = inp.value;
flashSuccess(targetInputs[idx]);
updates.push({ plan_date: targetDate, service: targetService, person_id: targetInputs[idx].dataset.p, meal_name: inp.value });
}
});
}
closeCopyModal();
if (updates.length > 0) {
const fd = new FormData();
fd.append('action', 'save_bulk');
fd.append('updates', JSON.stringify(updates));
await pachaFetch('/modules/meals/includes/api/save-meals.php', { method: 'POST', body: fd });
}
};
function flashSuccess(input) {
input.style.transition = 'background 0.3s';
input.style.background = 'var(--alert-success-bg)';
setTimeout(() => input.style.background = 'transparent', 400);
}
// NOUVEAU : Fonction Javascript d'Import PDF
window.importPdfMenu = async function(e) {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('btnImportPdf');
const oldText = btn.innerText;
btn.disabled = true;
btn.innerText = '⏳...';
const fd = new FormData(form);
const actionUrl = form.getAttribute('action');
try {
const res = await pachaFetch(actionUrl, { method: 'POST', body: fd });
if (res && res.success) {
document.getElementById('importMenuModal').classList.remove('open');
document.body.classList.remove('no-scroll');
if (window.showToast) showToast(window.I18N['meal_import_success'] || 'Menus importés !', 'success');
setTimeout(() => window.location.reload(), 1000);
} else {
if (window.showToast) showToast(res.error || window.I18N['error_occured'], 'error');
else alert(res.error);
}
} catch(err) {
if (window.showToast) showToast(window.I18N['error_occured'], 'error');
} finally {
btn.disabled = false;
btn.innerText = oldText;
form.reset();
document.getElementById('pdfFileName').innerText = window.I18N['meal_drop_pdf'] || 'Cliquer pour ajouter le PDF';
}
};
</script>