Author SHA1 Message Date
FeFeClochette 3e4374392a refacto : var currency and zone applied 2026-05-19 20:48:39 +02:00
percoandClaude Sonnet 4.6 ab97af0ea3 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>
2026-05-19 19:25:09 +02:00
FeFeClochette cbb8f93435 Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-19 19:16:47 +02:00
FeFeClochette 16bab06b71 Multi-tenant (p.1) 2026-05-19 19:15:38 +02:00
percoandClaude Sonnet 4.6 969bd5c2bf fix(planka): tri intra-liste par drop sur moitié haute/basse de carte
Deploy HouseHub / deploy (push) Successful in 1s
- Chaque carte est une zone de drop: moitié haute = avant, moitié basse = après
- Indicateur visuel: bordure bleue top ou bottom selon la position du curseur
- Suppression des pk-drop-before (zones 4px invisibles remplacées)
- Colonne comme zone de secours pour listes vides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:15:07 +02:00
percoandClaude Sonnet 4.6 e770ccf22b fix(planka): propagation erreurs Planka + noms étiquettes + position dans saveCard
Deploy HouseHub / deploy (push) Successful in 2s
- update_card: transmet position à Planka + retourne erreur réelle si Planka rejette
- saveCard: n'envoie listId que si changé (position requise par Planka), sinon nom/desc/dueDate seul
- Labels: affiche le nom en texte dans le badge coloré (comme Planka)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:55:35 +02:00
percoandClaude Sonnet 4.6 5828d1796d fix(planka): endpoints v2, tri intra-liste, drag&drop robuste
Deploy HouseHub / deploy (push) Successful in 2s
- create_card: POST /api/lists/{id}/cards + type=story (Planka v2 requis)
- create_list: type=active requis
- Drag & drop: zones pk-drop-before avant chaque carte pour tri intra-liste
- moveCard(): position calculée (avg prev/next) + gestion même liste
- Suppression variable dragCardId globale → dataTransfer propre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:49:26 +02:00
percoandClaude Sonnet 4.6 0db14560a5 fix(planka): drag&drop via dataTransfer + create_list + synchro position
Deploy HouseHub / deploy (push) Successful in 2s
- Drag & drop: utilise e.dataTransfer (pas de variable globale fragile)
- dropCard prend cardId en paramètre, envoie position requis par Planka
- Nouveau bouton 'Ajouter une liste' en fin de board
- Endpoint create_list dans api.php (POST /api/boards/{id}/lists)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:16 +02:00
percoandClaude Sonnet 4.6 7d62aa02be fix(planka): position obligatoire pour déplacer une carte + filtre listes None
Deploy HouseHub / deploy (push) Successful in 2s
- dropCard envoie position (max+65535) car Planka l'exige pour changer listId
- Ajout vérification res.ok + toast d'erreur/succès
- Filtre les listes avec name='None' (placeholders Planka sans nom)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:30:12 +02:00
percoandClaude Sonnet 4.6 285273108c fix(planka): drag & drop toute colonne + filtre listes par boardId
Deploy HouseHub / deploy (push) Successful in 2s
- Drop target = toute la colonne .pk-cards (plus la zone invisible de 4px)
- Filtre les listes et cartes strictement par boardId pour éliminer les listes fantômes
- Indicateur visuel 'Déposer ici' sur colonnes vides
- CSS: colonne highlight en bleu pointillé au survol drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:21:31 +02:00
percoandClaude Sonnet 4.6 3516225e56 fix: corrige include paths et get_family_pdo() dans api.php Liste
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:13:49 +02:00
percoandClaude Sonnet 4.6 987ce43e52 fix: corrige parse error apostrophes fr.php et includes liste.php
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:10:55 +02:00
percoandClaude Sonnet 4.6 8176fc4de0 feat: remplace module Courses par Liste multi-listes nommées
Deploy HouseHub / deploy (push) Successful in 1s
- Nouveau module 'liste' : plusieurs listes nommées par espace famille
- Tabs de navigation entre les listes + création/renommage/suppression inline
- Migration automatique : existing pf_grocery_items → list_id=1 (Ma liste)
- Schema: nouvelle table pf_lists, ajout list_id sur pf_grocery_items
- Historique global partagé entre toutes les listes
- Ancien module 'groceries' conservé pour compatibilité ascendante
- Mise à jour settings.php, header.php, index.php, schema_family.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:07:02 +02:00
FeFeClochette 1a9d5d5d18 README : roadmap variabilisation
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-19 16:50:12 +02:00
perco 0ca3eef7c3 Planka: add to settings page + README section
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-18 12:54:35 +02:00
percoandClaude Sonnet 4.6 7a697213d4 Planka: add native Kanban module with proxy API and SPA board view
Deploy HouseHub / deploy (push) Successful in 1s
Adds modules/planka/api.php (PHP proxy to Planka at 10.200.33.10:1337), planka.css, and planka.php (full SPA with board selector, drag-and-drop cards, card modal with checklist). Registers planka in header nav, all 3 lang files, schema_family.sql and enables it for family 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:52:35 +02:00
perco 00db9dedfd README: mise à jour complète — tous les modules, PrintVault, Todo Discord, stack technique
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-16 11:29:41 +02:00
percoandClaude Sonnet 4.6 f2b1c05356 PrintVault: remove GCode segment cap + fix PHP upload limits
Deploy HouseHub / deploy (push) Successful in 2s
- PHP upload: 20M → 300M, post_max: 25M → 310M, memory: 256M → 512M
- GCode parser: no segment cap (full file), flat array format for compact JSON
  491k segments = 0.8s parse, 17MB JSON on 80MB GCode file
- Viewer JS updated for flat array stride-7 format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:46:13 +02:00
percoandClaude Sonnet 4.6 237de1e529 PrintVault: raise GCode segment limit to 300k (max 500k)
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:41:19 +02:00
percoandClaude Sonnet 4.6 811d186b46 PrintVault: GCode 3D path viewer with layer color gradient
Deploy HouseHub / deploy (push) Successful in 2s
- New API endpoint gcode_paths: parses G0/G1 moves, returns segments (max 30k)
- Three.js LineSegments renderer: extrusion moves colored by Z height
  Blue(low) → Cyan → Green → Yellow → Red(high), travel moves dimmed
- Legend overlay showing color scale
- GCode files now show viewer + metadata cards (layout unchanged)
- PHP syntax validated, API endpoint tested before deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:34:28 +02:00
percoandClaude Sonnet 4.6 b756517ab5 PrintVault: direct page navigation + GCode metadata cards
Deploy HouseHub / deploy (push) Successful in 2s
- Clicking a model card now navigates to /printvault-viewer.php (no popup)
- viewer page uses HouseHub header/nav (integrated look)
- GCode metadata displayed as cards: ⏱ time, 🧵 filament, 🔥 nozzle, 🟦 bed
- Two-column layout: Three.js viewer left + metadata sidebar right
- viewer.css: new stylesheet for detail page
- Removed now-unused detail/edit modals from printvault.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:05:58 +02:00
percoandClaude Sonnet 4.6 1ee0ec820c Fix PrintVault upload: unpack format f3 (not fff) + ob_end_clean() + perms
Deploy HouseHub / deploy (push) Successful in 2s
- unpack('fff',...) in PHP returns only 1 float — must use unpack('f3',...)
  to correctly extract 3 floats from binary STL vertices
- ob_end_clean() in pvOk/pvErr discards PHP warnings from output buffer
  (display_errors=On was causing warnings to corrupt JSON response)
- Upload dirs chowned to www-data so Apache can write files
- QA verified: categories(8), STL parse, DB insert, file write all pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:58:53 +02:00
percoandClaude Sonnet 4.6 99e135ec74 PrintVault: full native rewrite — local storage, MariaDB, Three.js viewer
Deploy HouseHub / deploy (push) Successful in 1s
- All files stored in HouseHub (/uploads/printvault/), zero dependency on external service
- PHP parsers: binary/ascii STL (dimensions + volume), 3MF (XML bounding box), GCode metadata
- Three.js viewer (STL + 3MF) in printvault-viewer.php with importmap CDN
  · OrbitControls, metallic material, environment lighting, grid floor
  · Screenshot button saves thumbnail back to HouseHub
- printvault.php: model grid + upload drag&drop + detail modal + edit modal
- API: models CRUD, categories, file serve (inline for viewer), thumb save
- pf_pv_models + pf_pv_categories tables added to schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:25:21 +02:00
percoandClaude Sonnet 4.6 7272772c9f Add PrintVault module: 3D print file manager integrated in HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
- printvault.php: SPA with model grid, sidebar (categories + type filters), search
- modules/printvault/api.php: PHP proxy to PrintVault API (auth + CORS)
- modules/printvault/assets/printvault.js: model grid, upload modal, detail modal
- modules/printvault/assets/printvault.css: full light/dark theme
- Upload: drag&drop + form (name, category, tags)
- Detail modal: metadata, dimensions, GCode info, download, delete, "Voir en 3D" → PrintVault
- Integrated in header nav, home page, settings; translations fr/en/ca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:14:03 +02:00
Cedric 9aa61c85df changement
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-15 09:21:25 +02:00
perco 050d8eda8b todo correctif
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:40:14 +02:00
perco 3e58b00c1f correctif
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:32:44 +02:00
perco f2c2a43d22 correction
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 22:26:00 +02:00
perco b2a3e34bbe correction garage
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-14 22:15:55 +02:00
perco f62f724046 liste V4
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 11:36:41 +02:00
perco 3a2c8eb5cf liste V3
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 10:52:59 +02:00
perco 9ca290c8ac correction liste de course
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-14 10:47:44 +02:00
perco e6de684f28 ajout liste de course
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-14 10:43:17 +02:00
Cedric a2e0061dde calDAV v5
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 18:26:09 +02:00
Cedric d9d9587ded calDAV V4
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 17:25:56 +02:00
Cedric 8cb9d37154 calDAV V3
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 17:17:40 +02:00
Cedric c62e6ef52d correction note
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 16:45:25 +02:00
Cedric 5b74c6da04 calDAV v3
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 16:20:33 +02:00
Cedric d660d2cc7a calDAV v2
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 15:54:10 +02:00
Cedric 912734d2cc calendrier
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-13 14:37:38 +02:00
Cedric 1c274289bf ajout calendrier ios
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-13 14:00:29 +02:00
Cedric 63833abbc7 test
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 17:41:22 +02:00
Cedric b3f320975b settings correction
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 17:23:24 +02:00
Cedric fd53b33e9f settings correction
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 16:52:27 +02:00
percoandClaude Sonnet 4.6 f68c2b7259 Todo cron: rich Discord embed with notes, priority, list + fix double execute()
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:48:48 +02:00
percoandClaude Sonnet 4.6 cfc115665b Todo cron: fix timezone (Europe/Paris) — container runs UTC, tasks stored in local time
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:39:20 +02:00
percoandClaude Sonnet 4.6 401e98e9ee Todo cron: exact HH:MM match (like original app) + fix log path
Deploy HouseHub / deploy (push) Successful in 2s
- Match reminder_time = HH:MM exactly, runs once per minute naturally
- Log was failing silently: /var/log not writable by perco user
- Fixed crontab to log to /home/perco/househub_todo_cron.log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:06:05 +02:00
percoandClaude Sonnet 4.6 c3229cbf11 Fix todo API: PHP warning on missing due_date key corrupted JSON output
Deploy HouseHub / deploy (push) Successful in 1s
Use ?? null before ?: null for optional fields; add ob_start() as safety net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:42:52 +02:00
percoandClaude Sonnet 4.6 9ec78ba77e Todo: fix JSON.parse error + remove quick-add bar
Deploy HouseHub / deploy (push) Successful in 1s
- api() now sends credentials + Accept/X-Requested-With headers so session
  expiry returns proper JSON 401 instead of HTML redirect
- Safer JSON parsing with readable error in console
- Remove quick-add bar (use modal only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:34:41 +02:00
percoandClaude Sonnet 4.6 606dcbf3d7 Todo: daily reminders at fixed time, no date, simplified sidebar
Deploy HouseHub / deploy (push) Successful in 2s
- Remove date field; due_time is now required
- Cron repeats daily via notified_date (resets at midnight)
- Remove Today/Upcoming/Overdue smart views from sidebar
- Task card shows bell icon + time instead of date labels
- Creation no longer sends immediate Discord notification (cron only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:09:46 +02:00
percoandClaude Sonnet 4.6 5d5ccf406d Fix cron: notify tasks with due_time but no due_date (treat as today)
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:06:28 +02:00
percoandClaude Sonnet 4.6 c758337ff3 Fix budget: widen month columns VARCHAR(7→10) to match Y-m-01 query format
Deploy HouseHub / deploy (push) Successful in 2s
pf_expenses.gestion_month, pf_alloc_values.month_date, pf_savings.month_date
were VARCHAR(7) but budget views query with Y-m-01 (10 chars) — converted
all imported data in househub_f2 accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:02:41 +02:00
percoandClaude Sonnet 4.6 2016407a43 Home: meaningful CTAs per module (Réviser, Griffonner, Cocher, Déballer)
Deploy HouseHub / deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:57:22 +02:00
percoandClaude Sonnet 4.6 d913f3c0eb Todo: scheduled Discord notifications via cron
Deploy HouseHub / deploy (push) Successful in 2s
- cron/todo_notify.php: checks every minute for tasks due now and sends
  Discord reminder () only when due_date+due_time is reached
- No immediate notification when due_time is set (cron handles it)
- Immediate notification still fires for tasks without a due_time
- notified column tracks whether reminder was already sent
- Resets notified=0 when a task is edited (new time picked)
- Cron runs via: docker exec househub php /var/www/html/cron/todo_notify.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:55:27 +02:00
percoandClaude Sonnet 4.6 6ef3b26153 Add due_time field to Todo tasks
Deploy HouseHub / deploy (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:52:04 +02:00
percoandClaude Sonnet 4.6 7790375c01 Fix Todo module: ID mismatches, overdue filter, Discord webhook settings
Deploy HouseHub / deploy (push) Successful in 2s
- Rewrite todo.php with correct element IDs matching todo.js
- Fix sidebar: fully dynamic rendering (no more static nav items)
- Fix quick-add: list selector added, Enter key works correctly
- Fix modals: todo-form-*, list-form-name, list-icon-opts IDs
- Fix overdue filter missing from API (list_id=overdue)
- Add settings modal (⚙️) with Discord webhook URL field
- Add settings action in API (GET/PUT via pf_notes table)
- Discord webhook: notify on task create and task done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:46:32 +02:00
percoandClaude Sonnet 4.6 b97cba6e75 Add Todo module: SPA task manager with lists, priorities and due dates
Deploy HouseHub / deploy (push) Successful in 1s
- New todo.php SPA page with sidebar (smart views + user lists)
- modules/todo/api.php: REST API for lists and todos (CRUD, filters, stats)
- modules/todo/assets/todo.js: frontend (quick add, toggle done, modals)
- modules/todo/assets/todo.css: full light/dark theme styles
- pf_todo_lists + pf_todos tables added to schema_family.sql
- Integrated in header nav, home page cards, settings module list
- Translations added in fr/en/ca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:36:25 +02:00
percoandClaude Sonnet 4.6 5a81c1e9f0 Module Memo : intégration PercoMemo dans HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
- SPA PHP/MySQL avec rendu Markdown client-side (marked.js)
- Notes par famille : titre, contenu Markdown, tags, pièces jointes
- Attachements : images, fichiers, URLs avec galerie lightbox
- Recherche full-text (LIKE) + filtrage par tag dans la sidebar
- Preview live Markdown en split-view lors de l'édition
- Drag & drop images + paste depuis presse-papier
- Tables pf_memo_notes + pf_memo_attachments dans schema_family.sql
- Migration des 2 notes existantes depuis PercoMemo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:21:57 +02:00
Cedric 3e344f4c1c debug
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 12:05:28 +02:00
Cedric 19ab75f7df test debug dark mode
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 12:00:08 +02:00
Cedric 35c3ef020d dark mode holydays
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 11:53:50 +02:00
Cedric 1ddd080c16 dark mode holydays
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 11:51:59 +02:00
Cedric c25d784471 Merge branches 'main' and 'main' of http://192.168.1.29:3500/perco/HouseHub
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-12 11:37:35 +02:00
Cedric d9d310da37 dark-mode cadeaux 2026-05-12 11:37:33 +02:00
percoandClaude Sonnet 4.6 1e43f4bdc1 Uniformisation body : data-page au lieu de classes par page
Deploy HouseHub / deploy (push) Successful in 2s
- <body data-page="pageName"> uniforme sur toutes les pages
- <main> reçoit la classe module via $mainClass (pas le body)
- home.css : body.pf-home → body[data-page="home"]
- family-calendar.css : .pf-family-calendar .pf-main → .pf-family-calendar.pf-main
- dark-mode.css : mise à jour références body.pf-home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:36:17 +02:00
percoandClaude Sonnet 4.6 63d71ab7e4 Settings : upload image fond page d'accueil
Deploy HouseHub / deploy (push) Successful in 1s
- Section "Image d'accueil" dans settings.php (par famille)
- Stockage dans /uploads/home_bg_{family_id}.{ext}
- Aperçu de l'image actuelle + bouton reset vers le défaut
- /home-bg.php : endpoint sécurisé pour servir l'image
- index.php : override inline CSS si image personnalisée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:40:19 +02:00
Cedric 87eb3189ec rollback
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 09:11:36 +02:00
Cedric c94af542ff test
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-12 09:10:28 +02:00
percoandClaude Sonnet 4.6 aff2d24a34 CI/CD : Gitea Actions deploy + source code volume mount
Deploy HouseHub / deploy (push) Successful in 5s
- .gitea/workflows/deploy.yml : git pull via SSH au push sur main
  → plus besoin de rebuilder le container pour les modifs PHP/CSS/JS
- docker-compose.yml : monte /opt/container/househub:/var/www/html
  → les fichiers sont servis directement depuis le repo local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:07:36 +02:00
Cedric b87146c9cf modif background
Deploy to Pacha Family / deploy (push) Failing after 3s
2026-05-12 09:01:30 +02:00
percoandClaude Sonnet 4.6 20989d608a Dark mode : fix !important + header accueil
Deploy to Pacha Family / deploy (push) Failing after 3s
- Ajout !important sur tous les overrides qui en ont besoin (battaient
  les règles hardcodées des modules avec !important)
- Header page d'accueil : override explicite de body.pf-home .pf-header
- Calendar : planningTable, sticky cols, fc-view-controls
- Budget : th.sticky-col, tab-item.active, sum-selected
- Holidays/gifts : tous les éléments clés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:29:38 +02:00
percoandClaude Sonnet 4.6 89299613cb Dark mode : coverage complète tous modules (budget, calendrier, voyages, cadeaux, accueil)
Deploy to Pacha Family / deploy (push) Failing after 3s
Nouveau dark-mode.css chargé globalement après tous les CSS :
- Accueil : cartes modules avec fond sombre semi-transparent
- Budget : tabs, owner-tabs, table, sticky-col, nens-tables
- Calendrier : cells, nav, vue, balance cards, chips
- Vacances : idea-cards, filtres, formulaires
- Cadeaux : gift-cards, child-header, filter-bar, summary-pills

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:26:23 +02:00
percoandClaude Sonnet 4.6 8584bf8c1f Home : ordre modules = menu, ajout Garage, respect enabled_modules
Deploy to Pacha Family / deploy (push) Failing after 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:19:05 +02:00
percoandClaude Sonnet 4.6 b9403ef86b Dark mode : pill toggle switch ☀️/🌙 + fix JS
Deploy to Pacha Family / deploy (push) Failing after 3s
Remplace le bouton emoji par un vrai switch pill CSS (track + thumb
glissant, soleil/lune qui s'estompent). État visuel entièrement piloté
par [data-theme="dark"] via CSS, JS se contente de toggler l'attribut.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:47:07 +02:00
percoandClaude Sonnet 4.6 19031548e1 Dark mode : toggle 🌙/☀️ dans le header, thème persisté en localStorage
Deploy to Pacha Family / deploy (push) Failing after 3s
- [data-theme="dark"] sur <html> avec surcharge des variables CSS
- Couverture complète : header, nav, cartes, modals, formulaires,
  tables, toasts — global + module garage
- Script early-apply dans <head> pour éviter le flash au rechargement
- Bouton toggle dans le header (desktop + mobile via le burger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:42:27 +02:00
percoandClaude Sonnet 4.6 87035ed3b6 Garage : liste radio scrollable pour pièces connues
Deploy to Pacha Family / deploy (push) Failing after 3s
Remplace les chips par une liste scrollable (max 160px) avec
nom, référence, marque et prix — identique au GarageManager original.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:38:26 +02:00
percoandClaude Sonnet 4.6 b338d7550a Garage : fix select avec valeurs hors-liste (type, fuel_type, category)
Deploy to Pacha Family / deploy (push) Failing after 3s
setSelectVal() ajoute dynamiquement l'option si elle n'existe pas dans
le select, évitant que .value reste vide et soit envoyé null à l'API.
Validation PHP côté serveur : type et date requis sur PUT maintenance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:36:12 +02:00
percoandClaude Sonnet 4.6 5ac4e51efb Garage API : gestion d'erreurs robuste (JSON garanti)
Deploy to Pacha Family / deploy (push) Failing after 3s
- set_exception_handler global : toute exception PDO retourne du JSON
  au lieu d'une page HTML qui casse le JSON.parse côté client
- gOk() : JSON_INVALID_UTF8_SUBSTITUTE pour données UTF-8 corrompues,
  vérification du retour de json_encode
- db.php die() : retourne JSON au lieu de texte brut

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:31:29 +02:00
percoandClaude Sonnet 4.6 7cacf4bdf5 Garage : redesign CSS thème clair HouseHub
Deploy to Pacha Family / deploy (push) Failing after 3s
Remplace le thème sombre GarageManager par le thème clair de HouseHub
(variables --bg-panel, --border-light, --text-main, --primary, etc.)
Cohérent avec global.css et les autres modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:25:31 +02:00
percoandClaude Sonnet 4.6 eb34657473 Garage module : toutes les features GarageManager
Deploy to Pacha Family / deploy (push) Failing after 3s
- Navigation vers détail entretien (page-maintenance) au clic sur une ligne
- Boutons édition sur entretiens et pièces (openEditMaintenance, openEditPart)
- Formulaire inline ajout pièce sur page entretien avec TVA +20%
- Chips "pièces connues" pour réutiliser rapidement une pièce existante
- Calcul prix TTC dans modal pièce (updatePartPriceTTC) et formulaire inline (updateIPriceTTC)
- API : GET par ID pour maintenances et pièces
- Correction champ garage : name="garage_name" cohérent POST/PUT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:57:57 +02:00
percoandClaude Sonnet 4.6 a443ec389b fix: garage — photos (limite 20MB), entretiens globaux, pièces groupées par véhicule
Deploy to Pacha Family / deploy (push) Failing after 3s
- Dockerfile + docker/php.ini : upload_max_filesize 2M → 20M
- garage.php : onglet Entretiens dans sous-nav + page #page-maintenances-all
- garage.js : loadAllMaintenances() (entretiens groupés par véhicule)
- garage.js : loadParts() redesigné — pièces groupées par véhicule
- garage.js : navigate() gère 'maintenances-all'
- DB : vehicle_id des pièces mis à jour via maintenance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:33:49 +02:00
percoandClaude Sonnet 4.6 52c8e82221 fix: garage — photos upload + JSON error champs numériques vides
Deploy to Pacha Family / deploy (push) Failing after 3s
- docker-compose.yml : suppression volume uploads sur MariaDB (mauvais container)
- modules/garage/api.php : conversion chaînes vides → null pour INT/DECIMAL (year, km, cost, price, quantity)
  empêche le SQLSTATE[22007] lors de l'édition de véhicule/entretien/pièce
- Photos : vérification que /uploads/garage/ est accessible en écriture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:20:40 +02:00
percoandClaude Sonnet 4.6 997d3872ef fix: garage — sous-nav interne + correction modal (.open → .show)
Deploy to Pacha Family / deploy (push) Failing after 2s
- Ajout sous-nav Dashboard/Véhicules/Pièces (nav-link attendus par garage.js)
- CSS modal-backdrop.open → .show (alignement avec garage.js openModal())
- Styles garage-subnav intégrés dans garage.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:12:18 +02:00
percoandClaude Sonnet 4.6 62eabfb5ee feat: module Garage — véhicules, entretiens, pièces (intégration GarageManager)
Deploy to Pacha Family / deploy (push) Failing after 3s
- modules/garage/api.php : API REST MySQL (vehicles, maintenances, parts, stats, photos)
- modules/garage/assets/garage.css + garage.js : UI adaptée (chemins API mis à jour)
- garage.php : page principale avec header/footer HouseHub + i18n
- docker/schema_family.sql : tables pf_vehicles, pf_maintenances, pf_parts
- docker-compose.yml : volume househub_uploads pour photos
- includes/lang/fr|ca|en.php : ~50 clés de traduction garage
- header.php : lien Garage dans nav desktop + mobile
- settings.php : module garage dans les toggles
- README.md : tableau des modules + mise à jour description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:57:24 +02:00
percoandClaude Sonnet 4.6 f6de4918bd fix: refus d'enregistrer si aucun module sélectionné (message d'erreur)
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:46:08 +02:00
percoandClaude Sonnet 4.6 3077db5b98 feat: gestion des modules actifs par famille dans settings.php
Deploy to Pacha Family / deploy (push) Failing after 3s
- families.enabled_modules (JSON) en BDD + init schema
- login.php charge les modules actifs en session
- header.php affiche uniquement les modules actifs (nav desktop + mobile)
- settings.php : section Modules avec toggles visuels (partagé par famille)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:40:50 +02:00
percoandClaude Sonnet 4.6 4b2ab275bc fix: ajout clé bud_cat_income manquante dans fr/ca/en
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:31:10 +02:00
percoandClaude Sonnet 4.6 a0f5f04274 fix: remplacement apostrophes typographiques par apostrophes échappées dans fr.php et ca.php
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:16:00 +02:00
percoandClaude Sonnet 4.6 b80a949701 fix: drapeaux FR et EN en SVG inline (compatibilité Windows/Chrome)
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:10:18 +02:00
percoandClaude Sonnet 4.6 d25fa4b8dc fix: autoriser les caractères accentués dans les noms d'utilisateur (é, è, à...)
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:05:27 +02:00
percoandClaude Sonnet 4.6 d856cd8189 fix: Senyera catalane en SVG inline (4 barres rouges sur or)
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:04:18 +02:00
percoandClaude Sonnet 4.6 472d6ac7fd fix: vrai drapeau catalan 🏴󠁥󠁳󠁣󠁴󠁿 dans le sélecteur de langue
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:00:25 +02:00
percoandClaude Sonnet 4.6 6acda4ab25 feat: sélecteur de langue dans settings.php — persisté par compte en BDD
Deploy to Pacha Family / deploy (push) Failing after 3s
- Sélecteur FR/EN/CA déplacé du header vers settings.php
- Colonne lang ajoutée à users (meta DB) — langue sauvegardée par compte
- Login charge automatiquement la langue préférée de l'utilisateur
- Header nettoyé du sélecteur de langue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:51:35 +02:00
percoandClaude Sonnet 4.6 94d85dc8da feat: ajout traduction anglaise (EN) — 548 clés traduites
Deploy to Pacha Family / deploy (push) Failing after 4s
- includes/lang/en.php : traduction complète EN (tests, nav, auth, home, calendrier, voyages, budget, cadeaux)
- includes/i18n.php : ajout 'en' dans les langues acceptées
- header.php : ajout du sélecteur EN dans la barre de langues
- README.md : documentation mise à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:44:23 +02:00
percoandClaude Sonnet 4.6 5bfc8efae4 fix: ajout liens settings + admin dans la navigation (header desktop + mobile)
Deploy to Pacha Family / deploy (push) Failing after 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:39:05 +02:00
percoandClaude Sonnet 4.6 0494554c30 feat: admin panel + page paramètres + README mis à jour
Deploy to Pacha Family / deploy (push) Failing after 3s
- admin/index.php : gestion familles et utilisateurs (activer/désactiver, rôles, supprimer, regénérer code invite)
- includes/admin_auth.php : protection des routes admin (403 si non-admin)
- settings.php : page de config par compte (profil, mot de passe, code invitation, membres, lien admin)
- login.php : vérification is_active user + famille, is_admin en session
- docker/init/00-setup.sql : ajout colonnes is_admin, is_active sur users + is_active sur families
- invite.php supprimé (remplacé par settings.php)
- README.md : documentation complète multi-tenant, URLs, rôle admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:30:44 +02:00
percoandClaude Sonnet 4.6 84db4b6619 feat: système multi-tenant (familles + inscription + invitations)
Deploy to Pacha Family / deploy (push) Failing after 3s
- docker/init/00-setup.sql : meta DB househub_meta (families + users) + grants
- docker/schema_family.sql : template DB par famille (toutes les pf_* tables)
- docker-compose.yml : MYSQL_DATABASE=househub_meta, init dir monté
- includes/meta_db.php : PDO vers househub_meta
- includes/db.php : connexion dynamique vers la DB famille (session)
- login.php : auth via meta DB + family_db en session + lien inscription
- register.php : inscription + création famille OU rejoindre via code
- invite.php : voir son code d'invitation + membres de l'espace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:23:53 +02:00
percoandClaude Sonnet 4.6 634e4647da feat: setup Docker + MariaDB pour déploiement self-hosted
Deploy to Pacha Family / deploy (push) Failing after 3s
- Dockerfile (PHP 8.2 Apache + pdo_mysql)
- docker-compose.yml (househub + mariadb + Traefik househub.nas.percolouco.com)
- schema.sql (22 tables + données initiales)
- includes/db.php adapté pour variables d'environnement
- .env.example, .gitignore, README mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:07:04 +02:00
percoandClaude Sonnet 4.6 3f7ed9c2f1 refactor: renommage PachaFamily → HouseHub
Deploy to Pacha Family / deploy (push) Failing after 3s
- Remplacement de toutes les occurrences PachaFamily/pachafamily/PACHAFAMILY
- Renommage pachafamily_source.md → househub_source.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:58:53 +02:00
FeFeClochette 29f341b052 fix calcul voyage
Deploy to Pacha Family / deploy (push) Failing after 3s
2026-05-06 21:46:03 +02:00
FeFeClochette 8792e44f69 paid 2026-05-06 21:37:08 +02:00
FeFeClochette e099fb80cd fix etapes duplicate 2026-05-06 21:27:03 +02:00
FeFeClochette 8a6abe5f8e opti vue mensuelle 2026-05-06 20:49:52 +02:00
FeFeClochette 2b2150870c header mobile 2026-05-06 20:07:20 +02:00
FeFeClochette 31f03b7182 padding logout 2026-05-06 20:04:24 +02:00
FeFeClochette f2af3541f6 header et login mobile 2026-05-06 19:56:18 +02:00
FeFeClochette ab1374472d Auto-commit for deploy to production - 2026-05-06 17:45:08 2026-05-06 17:45:08 +02:00
FeFeClochette 55a2f8a730 smart selector 2026-05-06 17:44:43 +02:00
FeFeClochette 3bfd25d566 infinite scroll 2026-05-06 13:24:27 +02:00
FeFeClochette ff04458a59 css family 2026-05-06 12:55:24 +02:00
Cedric 97389ab0ad debug perco 2026-05-06 12:14:33 +02:00
FeFeClochette 902f093ced login 2026-05-06 11:54:39 +02:00
FeFeClochette 954c07a838 css 2026-05-06 11:38:51 +02:00
FeFeClochette be2edcce82 filtre multiple 2026-05-05 20:50:22 +02:00
FeFeClochette 54edb5ed58 gift revamp 2026-05-05 19:17:42 +02:00
FeFeClochette 9c5620b3e3 harmo 2026-05-05 17:13:12 +02:00
FeFeClochette 4763e6a947 trad et modal 2026-05-05 14:56:42 +02:00
FeFeClochette 7b5c0be72b calendar display mobile 2026-05-05 12:13:41 +02:00
FeFeClochette a2870f2421 hide planning hebdo 2026-05-05 09:10:51 +02:00
FeFeClochette 5b050a7c24 fix holidays 2026-05-05 08:57:15 +02:00
FeFeClochette 2cb62d7ead trad 2026-05-04 18:34:08 +02:00
FeFeClochette 6ce49bffc0 Auto-commit for deploy to production - 2026-05-04 18:30:44 2026-05-04 18:30:44 +02:00
FeFeClochette cc937c544c Sécurisation AJAX 2026-05-04 18:27:38 +02:00
FeFeClochette 281a37c401 Toasts 2026-05-04 18:22:36 +02:00
FeFeClochette cdd2abaa96 Centralisation des constantes 2026-05-04 18:19:42 +02:00
FeFeClochette 1babf4c52e responsive 2026-05-04 15:41:35 +02:00
FeFeClochette 8c4c402f5b modale csv 2026-05-04 12:52:48 +02:00
FeFeClochette 2087cfb945 fix bugs 2026-05-04 11:34:33 +02:00
FeFeClochette 9070390770 Auto-commit for deploy to production - 2026-04-20 11:40:09 2026-04-20 11:40:09 +02:00
FeFeClochette daff54784f fix map 2026-04-20 11:29:26 +02:00
FeFeClochette e42808149d vue mobile 2026-04-19 20:45:53 +02:00
FeFeClochette 9cca683722 fix snapshot 2026-04-17 20:00:02 +02:00
FeFeClochette 377c404d49 meteo mobile 2026-04-17 18:38:13 +02:00
FeFeClochette 10b9f1bb0b preivision 2026-04-17 17:27:57 +02:00
FeFeClochette 12f9e27845 cURL 2026-04-17 16:56:14 +02:00
FeFeClochette fe39eb8f2c weather 2026-04-17 16:51:05 +02:00
FeFeClochette 0db6a71fd7 css planning 2026-04-13 11:29:29 +02:00
FeFeClochette 30599de125 secu menu 2026-04-13 11:18:11 +02:00
FeFeClochette 0412b60b97 trad mobile 2026-04-13 11:14:31 +02:00
FeFeClochette 6d4ba85d00 gift mobile 2026-04-13 11:11:31 +02:00
FeFeClochette feb2f158dd trad 2026-04-13 11:06:08 +02:00
FeFeClochette c4b80c3650 geoloc 2026-04-10 17:47:23 +02:00
FeFeClochette 25b1c27ba9 calendar 2026-04-09 20:42:46 +02:00
FeFeClochette 6d5982d923 fix sum 2026-04-08 16:04:52 +02:00
FeFeClochette 97a8fb546e translate 2026-04-02 17:41:50 +02:00
FeFeClochette 7df7144147 supp inter 2026-04-02 17:03:12 +02:00
FeFeClochette 2882c682f0 return 2026-04-02 16:58:58 +02:00
FeFeClochette eff1628115 fix modale 2026-04-02 16:35:41 +02:00
FeFeClochette d03f6bad80 fix button 2026-04-02 16:04:53 +02:00
FeFeClochette c2aa1e9f7b planning view 2026-04-02 15:54:40 +02:00
FeFeClochette e07c57ea56 maj MAP 2026-04-02 11:57:30 +02:00
FeFeClochette b425ec78aa focus etape 2026-04-02 11:32:42 +02:00
FeFeClochette ebfa0c99f3 notes voyage 2026-04-01 21:18:18 +02:00
FeFeClochette 73676bc20a somme result 2026-03-30 17:49:39 +02:00
FeFeClochette 3930f5f1be fix order 2026-03-30 12:55:51 +02:00
FeFeClochette ca9c774422 css drag and drop 2026-03-30 12:33:57 +02:00
FeFeClochette a403628c07 geoloc 2026-03-30 12:21:47 +02:00
FeFeClochette 7096391561 css 2026-03-30 12:12:14 +02:00
FeFeClochette cdeff93fbd holidays 2026-03-30 12:01:44 +02:00
FeFeClochette 3f842e091a charge à venir 2026-03-30 10:26:43 +02:00
FeFeClochette aeb33930ab recap 2026-03-30 10:08:39 +02:00
FeFeClochette 106d6c99de suivi fix 2026-03-30 09:56:05 +02:00
FeFeClochette 3a8f58ef39 cycle 2026-03-28 18:55:45 +01:00
FeFeClochette 28b8a6d49a order by holidays 2026-03-28 18:20:21 +01:00
FeFeClochette 1f8b28cf8e fix duplicate 2026-03-28 17:41:46 +01:00
FeFeClochette 9424b44fd9 css 2026-03-23 20:13:11 +01:00
FeFeClochette 39b8b64116 input 2026-03-23 18:44:23 +01:00
FeFeClochette bc82a42517 charge 2026-03-23 17:49:41 +01:00
FeFeClochette 71ce5ce008 mapping cat 2026-03-23 17:39:56 +01:00
FeFeClochette 7501339b39 cycle 2026-03-23 17:36:23 +01:00
FeFeClochette 3db060bc0d fix catégorie 2026-03-23 13:53:10 +01:00
FeFeClochette 5859221fca signe expense 2026-03-23 09:48:19 +01:00
FeFeClochette 9b2cc83512 fix theo 2026-03-20 16:35:04 +01:00
FeFeClochette faaf89e1ab théorique 2026-03-20 16:20:49 +01:00
FeFeClochette 4c5646766a suivi 2026-03-20 12:16:58 +01:00
FeFeClochette 5b510a047f fix revenu 2026-03-13 20:32:20 +01:00
FeFeClochette 27564c8e9e note 2026-03-05 09:55:37 +01:00
FeFeClochette 7060a43859 change 2026-03-04 20:24:54 +01:00
FeFeClochette 730c2a363e css 2026-02-20 16:45:57 +01:00
FeFeClochette 5ae38e1e4b css 2026-02-20 16:41:27 +01:00
FeFeClochette e22a7e183c opti family calendar 2026-02-20 16:33:54 +01:00
FeFeClochette 80d39ab8b9 fix 2026-02-17 21:06:56 +01:00
FeFeClochette 695234d182 update epargne auto 2026-02-17 20:58:20 +01:00
FeFeClochette 8e3c90524e fix document 2026-02-17 18:11:16 +01:00
FeFeClochette 1f16135ba5 favicon et mobile 680 2026-02-17 18:06:02 +01:00
FeFeClochette 7c9ca1b705 virement todo 2026-02-17 17:12:59 +01:00
FeFeClochette 6e51a9215c budget 2026 2026-02-17 16:00:03 +01:00
FeFeClochette 36b54ea953 fix edit 2026-02-16 10:44:39 +01:00
FeFeClochette 6cbbfc4811 suivi 2026-02-16 10:40:18 +01:00
FeFeClochette cb2d0b5a06 fix à venir 2026-02-16 10:35:18 +01:00
FeFeClochette a90c1bc11b fix recap 2026-02-16 10:26:53 +01:00
FeFeClochette fde728c888 check à definir 2026-02-16 10:15:00 +01:00
FeFeClochette 910d21da5a income 2026-02-16 10:07:33 +01:00
FeFeClochette c01d793a1e depassement 2026-02-16 09:51:53 +01:00
FeFeClochette 0e6c3edbc3 is_estimate 2026-02-16 09:27:03 +01:00
FeFeClochette 4731efe615 auto check recap 2026-02-16 09:20:18 +01:00
FeFeClochette 92424bdb51 match recap 2026-02-15 12:21:18 +01:00
FeFeClochette 9a3d646ca5 fix php version 2026-02-15 10:51:12 +01:00
FeFeClochette 71db01d159 budget + 2026-02-11 11:50:18 +01:00
FeFeClochette 16e3eb0890 budget header 2026-02-10 21:25:09 +01:00
FeFeClochette 293bfbb0f1 reste a venir 2026-02-10 21:23:31 +01:00
FeFeClochette 5eb1aec8ff Auto-commit for deploy to production - 2026-02-09 17:53:11 2026-02-09 17:53:11 +01:00
FeFeClochette 54c8503ed1 mensuel 2026-02-09 17:46:10 +01:00
FeFeClochette 44e5241ee2 fix modale vacances 2026-02-09 15:33:20 +01:00
FeFeClochette d557636eee refacto holidays 2026-02-09 15:29:12 +01:00
FeFeClochette 43bcdc83a1 budget 2026-02-09 13:07:39 +01:00
FeFeClochette f5822f2694 calendar 2026-01-31 15:57:25 +01:00
FeFeClochette 170f98d8b8 index 2026-01-30 23:52:20 +01:00
FeFeClochette 94cde4ae34 revamp 2026-01-30 23:48:42 +01:00
FeFeClochette e248eee882 revamp gemini 2026-01-30 23:32:25 +01:00
FeFeClochette fbc95815d8 voyage 2026-01-26 20:21:05 +01:00
FeFeClochette c5b89f2bf4 edition suppression 2026-01-26 12:22:09 +01:00
FeFeClochette bc622f0b04 mobile lecture 2026-01-26 12:14:25 +01:00
FeFeClochette 564df35641 Visu mobile 2026-01-26 12:02:15 +01:00
FeFeClochette a8cd460cd6 bouton add 2026-01-26 10:45:24 +01:00
FeFeClochette 7e149d0c1a Anniversary 2026-01-26 10:31:32 +01:00
FeFeClochette c3ba14c8b0 ajustement tableau gift 2026-01-26 09:05:40 +01:00
FeFeClochette 6c8a7ab1b4 tricount 2026-01-23 15:38:01 +01:00
FeFeClochette b22348ebba img 2026-01-23 12:35:53 +01:00
FeFeClochette 3f01cbd8aa christmas 2026-01-23 12:23:47 +01:00
FeFeClochette 2df2fd0b2e boucle login 2026-01-09 14:55:06 +01:00
FeFeClochette 796d3b3fcc calendar storage 2026-01-08 17:25:42 +01:00
FeFeClochette b7533a4d05 bulk 2026-01-07 18:41:12 +01:00
FeFeClochette eee36e4034 fix bulk menu 2026-01-07 15:32:42 +01:00
FeFeClochette abe85cb5d3 menu open 2026-01-07 10:06:56 +01:00
FeFeClochette 907a17a2b9 login redirect 2026-01-07 09:43:38 +01:00
FeFeClochette ab46c105f7 pep malade 2026-01-07 09:36:11 +01:00
FeFeClochette 3b895f3f0c login 2025-12-24 16:19:45 +01:00
FeFeClochette a70cfff122 mobile 2025-12-24 16:14:56 +01:00
FeFeClochette e615156347 list 2025-12-24 16:01:18 +01:00
FeFeClochette 9beb6e1b43 layout 2025-12-24 14:48:49 +01:00
Cedric c6793e2473 picto 2025-12-11 15:28:51 +01:00
Cedric e86816ac7e Auto-commit for deploy to production - 2025-12-11 15:23:25 2025-12-11 15:23:25 +01:00
Cedric a08dc62ba6 Auto-commit for deploy to production - 2025-12-11 15:21:23 2025-12-11 15:21:23 +01:00
Cedric 0de792781a Auto-commit for deploy to production - 2025-12-11 15:10:59 2025-12-11 15:10:59 +01:00
Cedric 4e4177d8d0 Auto-commit for deploy to production - 2025-12-11 15:06:01 2025-12-11 15:06:01 +01:00
Cedric 128a738fae Auto-commit for deploy to production - 2025-12-11 15:01:35 2025-12-11 15:01:35 +01:00
132 changed files with 48016 additions and 1411 deletions
+11 -11
View File
@@ -1,4 +1,5 @@
name: Deploy to Pacha Family
name: Deploy HouseHub
on:
push:
branches: [main]
@@ -7,14 +8,13 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Production
uses: SamKirkland/FTP-Deploy-Action@v4.3.4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
server: ${{ secrets.FTP_SERVER_PERCOLOUCO }}
username: ${{ secrets.FTP_USERNAME_PERCOLOUCO }}
password: ${{ secrets.FTP_PASSWORD_PERCOLOUCO }}
local-dir: ./
server-dir: /www/pachafamily/ # ← Production reste ici
host: 192.168.1.29
username: perco
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/container/househub
git pull
echo "✓ HouseHub mis à jour"
+3
View File
@@ -0,0 +1,3 @@
househub_db_data
.env
data/.hh_app_secret
+14
View File
@@ -0,0 +1,14 @@
FROM php:8.2-apache
RUN apt-get update && apt-get install -y \
libpng-dev libjpeg-dev libwebp-dev libzip-dev libonig-dev \
&& docker-php-ext-install pdo pdo_mysql mysqli mbstring zip \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/uploads.ini
+187 -1
View File
@@ -1 +1,187 @@
pachafamily
# 🦙 HouseHub OS
Système de gestion familiale sur-mesure — Budget, Calendrier, Voyages, Tâches, Courses, Notes, Impression 3D et plus.
Stack : PHP 8.2 natif · Vanilla JS · MariaDB 11 · Docker · Traefik
---
## 🚀 Déploiement (Docker)
**Prérequis** : Docker, Docker Compose, Traefik sur le réseau `proxy`.
```bash
# 1. Cloner le repo
git clone http://<gitea>/perco/HouseHub.git && cd HouseHub
# 2. Créer le fichier d'environnement
cp .env.example .env
# Éditer .env pour changer les mots de passe
# 3. Lancer
docker compose up -d --build
# 4. Accéder à l'app
# https://househub.nas.percolouco.com
```
La base de données meta (`househub_meta`) et les permissions sont **initialisées automatiquement** au premier démarrage via `docker/init/00-setup.sql`.
Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial.
---
## 👥 Gestion multi-tenant
HouseHub supporte plusieurs familles indépendantes sur la même instance.
| URL | Description |
| --------------- | ------------------------------------------------------------------- |
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre via code d'invitation |
| `/login.php` | Connexion |
| `/settings.php` | Profil, mot de passe, langue, modules actifs, code d'invitation |
| `/admin/` | Panneau d'administration (admin uniquement) |
### Inviter quelqu'un dans son espace
1. Aller sur `/settings.php` → copier le code d'invitation
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
3. Les deux comptes partagent le même environnement de données
### Rôle admin
Le premier utilisateur inscrit doit être promu admin manuellement :
```sql
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
```
---
## 🧩 Modules
Tous les modules sont activables/désactivables par famille depuis `/settings.php`.
| Module | Route | Description |
| ----------------- | ---------------------- | ------------------------------------------------------------------------------- |
| 💰 Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne, import CSV bancaire |
| 📅 Calendrier | `/family-calendar.php` | Congés (CP/JRA/JA), modes de garde, planning hebdo, vacances scolaires |
| 🗓️ Calendrier iOS | `/calendar-ios.php` | Synchronisation CalDAV avec iPhone/iPad |
| 🏖️ Voyages | `/holidays.php` | Roadtrips, cartographie Leaflet, itinéraires OSRM, météo Open-Meteo |
| 🎁 Cadeaux | `/gift-list.php` | Listes Noël / anniversaires, intégration Tricount |
| 🚗 Garage | `/garage.php` | Véhicules, historique entretiens, pièces détachées |
| 🛒 Courses | `/groceries.php` | Liste de courses partagée en temps réel |
| ✅ Todo | `/todo.php` | Listes de tâches avec rappels quotidiens via Discord webhook |
| 📝 Mémo | `/memo.php` | Notes personnelles / familiales avec éditeur riche |
| 🖨️ PrintVault | `/printvault.php` | Gestionnaire de fichiers d'impression 3D (STL, 3MF, GCode) avec viewer Three.js |
| 📋 Planka | `/planka.php` | Kanban natif connecté à Planka — boards, listes, cartes, drag & drop, checklist |
---
## 📋 Planka
Module Kanban natif connecté à l'instance Planka du NAS — affichage et gestion directement dans HouseHub sans iframe.
- **Boards** : sélecteur de boards du projet Planka lié à la famille
- **Colonnes** : listes Planka affichées en colonnes scrollables
- **Cartes** : labels colorés, date d'échéance, checklist
- **Actions** : créer, modifier, supprimer des cartes ; drag & drop entre colonnes ; cocher les tâches
- **Par famille** : chaque espace HouseHub peut pointer vers son propre projet Planka
- **Paramètres** : bouton ⚙️ pour configurer le `project_id` Planka
---
## 🖨️ PrintVault
Module de gestion de fichiers d'impression 3D entièrement intégré à HouseHub.
- **Formats supportés** : STL (binaire/ASCII), 3MF, GCode
- **Viewer 3D** : Three.js (STL/3MF) et visualisation des trajectoires GCode avec dégradé de couleur par hauteur Z
- **Métadonnées GCode** : temps d'impression estimé, longueur filament, température buse/plateau
- **Stockage local** : fichiers sur le serveur, métadonnées en MariaDB (`pf_pv_*`)
- **Limite upload** : 300 Mo
---
## ✅ Todo — Rappels Discord
Le module Todo supporte des rappels quotidiens via un **webhook Discord** :
1. Aller sur `/todo.php` → ⚙️ → coller l'URL du webhook Discord
2. Configurer le cron sur l'hôte :
```bash
* * * * * docker exec househub php /var/www/html/cron/todo_notify.php >> /home/perco/househub_todo_cron.log 2>&1
```
Le cron tourne chaque minute et envoie une notification Discord à l'heure exacte configurée sur chaque tâche.
---
## 🛠️ Stack technique
| Composant | Technologie |
| -------------------- | ---------------------------------------- |
| Backend | PHP 8.2 (Apache) |
| Base de données | MariaDB 11 |
| Frontend | Vanilla JS, CSS custom (dark mode natif) |
| Viewer 3D | Three.js (ES modules via importmap CDN) |
| Cartographie | Leaflet + OSRM |
| Conteneurisation | Docker + Docker Compose |
| Reverse proxy | Traefik |
| Internationalisation | FR / CA |
---
## 🔄 Mises à jour & Migrations SQL (Multi-Tenant)
L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`househub_f1`, `househub_f2`, etc.). Lors de l'ajout de nouvelles fonctionnalités impliquant des changements de structure SQL, il faut suivre une double procédure :
1. **Pour les futures familles (Initialisation) :**
Mettre à jour le fichier `docker/schema_family.sql`. Ce fichier est lu par `register.php` à la création d'un nouvel espace.
2. **Pour les familles existantes (Migration) :**
Créer ou mettre à jour le script `migrate.php` à la racine du projet. Ce script boucle sur toutes les bases existantes référencées dans `househub_meta.families` pour y injecter les requêtes `ALTER TABLE` ou `CREATE TABLE`.
- **Exécution** : Accéder à `https://votre-domaine.com/migrate.php` avec un navigateur.
- ⚠️ **Sécurité** : Pensez à supprimer ou sécuriser l'accès à ce fichier après exécution en production.
---
## 🗺️ Roadmap de Variabilisation (Migration Multi-Tenant)
Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés.
🟢 Niveau 1 : Facile (Constantes et configurations globales)
[x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD.
[ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer.
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
[ ] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée.
🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur)
[ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD.
[ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT).
[ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable.
[ ] Déductions du Reste à venir (Budget) : Rendre dynamiques les libellés de catégories ciblés pour le calcul automatique du reste à venir (Estimacio escola, Estimation gasolina, etc.).
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
[ ] Refonte relationnelle de la répartition (Budget) : Modifier la structure de la table pf_alloc_values pour supprimer les colonnes figées amount_alex et amount_laia et basculer sur un modèle relationnel (alloc_id, user_id, amount) capable de gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
[ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille.
[ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens.
[ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés.
[ ] Cycles de Congés : Rendre configurables les mois de début de calcul des droits aux congés (Août pour les CP, date d'anniversaire personnalisée pour les JA) par profil utilisateur.
---
## 📌 Versioning
| Version | Changements |
| ------- | ------------------------------------------------------------------------------------------ |
| v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration |
| v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP |
| v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées |
| v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) |
| v1.4.0 | Synchronisation CalDAV iOS |
| v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 |
| v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure |
| v1.1.0 | Refonte expérience mobile (Bottom Sheets) |
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage |
+196
View File
@@ -0,0 +1,196 @@
<?php
require __DIR__ . '/../includes/admin_auth.php';
require_once __DIR__ . '/../includes/meta_db.php';
require_once __DIR__ . '/../includes/i18n.php';
// ─── Actions POST ──────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$id = (int)($_POST['id'] ?? 0);
if ($action === 'toggle_user' && $id) {
$meta_pdo->prepare("UPDATE users SET is_active = 1 - is_active WHERE id = ?")->execute([$id]);
} elseif ($action === 'toggle_family' && $id) {
$meta_pdo->prepare("UPDATE families SET is_active = 1 - is_active WHERE id = ?")->execute([$id]);
} elseif ($action === 'toggle_admin' && $id) {
// Ne peut pas se retirer ses propres droits admin
if ($id !== (int)$_SESSION['user']['id']) {
$meta_pdo->prepare("UPDATE users SET is_admin = 1 - is_admin WHERE id = ?")->execute([$id]);
}
} elseif ($action === 'delete_user' && $id) {
if ($id !== (int)$_SESSION['user']['id']) {
$meta_pdo->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
}
} elseif ($action === 'regen_invite' && $id) {
$new_code = bin2hex(random_bytes(8));
$meta_pdo->prepare("UPDATE families SET invite_code = ? WHERE id = ?")->execute([$new_code, $id]);
}
header('Location: /admin/');
exit;
}
// ─── Données ──────────────────────────────────────────────────────────────────
$families = $meta_pdo->query("
SELECT f.*, COUNT(u.id) as member_count
FROM families f
LEFT JOIN users u ON u.family_id = f.id
GROUP BY f.id
ORDER BY f.id
")->fetchAll();
$users = $meta_pdo->query("
SELECT u.*, f.name as family_name
FROM users u
LEFT JOIN families f ON f.id = u.family_id
ORDER BY u.id
")->fetchAll();
$pageTitle = "Admin — HouseHub";
$activePage = "admin";
require __DIR__ . '/../header.php';
?>
<div class="pf-container" style="max-width:1000px;margin:32px auto;padding:0 16px">
<h1 style="font-size:1.5rem;font-weight:700;margin-bottom:4px">⚙️ Panneau d'administration</h1>
<p style="color:#64748b;margin-bottom:32px">Gestion des espaces familiaux et des utilisateurs</p>
<!-- ── Familles ─────────────────────────────────────────────────────────── -->
<h2 style="font-size:1.1rem;font-weight:600;margin-bottom:16px">
🏠 Espaces familiaux
<span style="background:#e2e8f0;border-radius:12px;padding:2px 10px;font-size:0.85rem;font-weight:500;margin-left:8px"><?= count($families) ?></span>
</h2>
<div style="overflow-x:auto;margin-bottom:40px">
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
<thead>
<tr style="background:#f8fafc;text-align:left">
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">#</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Nom</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">DB</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Code invitation</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Membres</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Statut</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($families as $f): ?>
<tr style="border-bottom:1px solid var(--border-light)">
<td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td>
<td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td>
<td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td>
<td style="padding:10px 14px">
<code style="font-size:0.85rem"><?= $f['invite_code'] ?></code>
</td>
<td style="padding:10px 14px;text-align:center"><?= $f['member_count'] ?></td>
<td style="padding:10px 14px">
<span style="padding:3px 10px;border-radius:12px;font-size:0.8rem;font-weight:600;
background:<?= $f['is_active'] ? '#dcfce7' : '#fee2e2' ?>;
color:<?= $f['is_active'] ? '#16a34a' : '#dc2626' ?>">
<?= $f['is_active'] ? 'Actif' : 'Inactif' ?>
</span>
</td>
<td style="padding:10px 14px;display:flex;gap:8px;flex-wrap:wrap">
<form method="post" style="display:inline">
<input type="hidden" name="action" value="toggle_family">
<input type="hidden" name="id" value="<?= $f['id'] ?>">
<button class="pf-btn btn-secondary" style="padding:4px 12px;font-size:0.8rem">
<?= $f['is_active'] ? 'Désactiver' : 'Activer' ?>
</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="regen_invite">
<input type="hidden" name="id" value="<?= $f['id'] ?>">
<button class="pf-btn btn-secondary" style="padding:4px 12px;font-size:0.8rem"
onclick="return confirm('Regénérer le code ?')">
🔄 Code
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- ── Utilisateurs ─────────────────────────────────────────────────────── -->
<h2 style="font-size:1.1rem;font-weight:600;margin-bottom:16px">
👤 Utilisateurs
<span style="background:#e2e8f0;border-radius:12px;padding:2px 10px;font-size:0.85rem;font-weight:500;margin-left:8px"><?= count($users) ?></span>
</h2>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
<thead>
<tr style="background:#f8fafc;text-align:left">
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">#</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Utilisateur</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Espace famille</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Rôle</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Statut</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Créé le</th>
<th style="padding:10px 14px;border-bottom:2px solid #e2e8f0">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?>
<tr style="border-bottom:1px solid var(--border-light)<?= $isSelf ? ';background:#fafff4' : '' ?>">
<td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td>
<td style="padding:10px 14px">
<strong><?= htmlspecialchars($u['display_name']) ?></strong>
<span style="color:#94a3b8;font-size:0.85rem;margin-left:6px">@<?= htmlspecialchars($u['username']) ?></span>
<?php if ($isSelf): ?><span style="font-size:0.75rem;color:#2563eb;margin-left:4px">(vous)</span><?php endif; ?>
</td>
<td style="padding:10px 14px"><?= $u['family_name'] ? htmlspecialchars($u['family_name']) : '<span style="color:#94a3b8">—</span>' ?></td>
<td style="padding:10px 14px">
<?php if ($u['is_admin']): ?>
<span style="background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:10px;font-size:0.8rem;font-weight:600">Admin</span>
<?php else: ?>
<span style="color:#94a3b8;font-size:0.85rem">Membre</span>
<?php endif; ?>
</td>
<td style="padding:10px 14px">
<span style="padding:3px 10px;border-radius:12px;font-size:0.8rem;font-weight:600;
background:<?= $u['is_active'] ? '#dcfce7' : '#fee2e2' ?>;
color:<?= $u['is_active'] ? '#16a34a' : '#dc2626' ?>">
<?= $u['is_active'] ? 'Actif' : 'Inactif' ?>
</span>
</td>
<td style="padding:10px 14px;color:#64748b;font-size:0.85rem"><?= date('d/m/Y', strtotime($u['created_at'])) ?></td>
<td style="padding:10px 14px">
<div style="display:flex;gap:6px;flex-wrap:wrap">
<?php if (!$isSelf): ?>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="toggle_user">
<input type="hidden" name="id" value="<?= $u['id'] ?>">
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem">
<?= $u['is_active'] ? 'Désactiver' : 'Activer' ?>
</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="toggle_admin">
<input type="hidden" name="id" value="<?= $u['id'] ?>">
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem">
<?= $u['is_admin'] ? '↓ Membre' : '↑ Admin' ?>
</button>
</form>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer <?= htmlspecialchars($u['username']) ?> ?')">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="id" value="<?= $u['id'] ?>">
<button class="pf-btn btn-secondary" style="padding:4px 10px;font-size:0.8rem;color:#dc2626">🗑</button>
</form>
<?php else: ?>
<span style="color:#94a3b8;font-size:0.8rem">—</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php require __DIR__ . '/../footer.php'; ?>
-36
View File
@@ -1,36 +0,0 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../includes/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['action']) || !isset($input['event_id'])) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action ou ID manquant.']);
exit;
}
$action = $input['action'];
$eventId = $input['event_id'];
try {
if ($action === 'delete') {
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
$stmt->execute([$eventId]);
echo json_encode(['status' => 'success', 'message' => 'Événement supprimé.']);
} elseif ($action === 'update' && isset($input['new_type'])) {
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
$stmt->execute([$input['new_type'], $eventId]);
echo json_encode(['status' => 'success', 'message' => 'Événement mis à jour.']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action non valide ou données manquantes.']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
?>
-367
View File
@@ -1,367 +0,0 @@
/* === RESET SIMPLE === */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #f5f5f5;
color: #222;
}
/* === LAYOUT GLOBAL === */
.pf-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
}
.pf-header {
background: #243b53;
color: #fff;
padding: 8px 0;
}
.pf-header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.pf-logo {
font-weight: 600;
font-size: 18px;
}
.pf-nav {
display: flex;
gap: 16px;
}
.pf-nav-link {
color: #d9e2ec;
text-decoration: none;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
}
.pf-nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
.pf-nav-link--active {
background: #102a43;
color: #f0f4f8;
}
.pf-main {
padding: 24px 0 48px;
}
.pf-section {
margin-top: 24px;
}
.pf-section--panel {
background: #fff;
border-radius: 8px;
padding: 16px 16px 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.pf-footer {
border-top: 1px solid #d9e2ec;
padding: 8px 0;
font-size: 12px;
color: #627d98;
background: #f0f4f8;
}
/* === CARDS & FLEX === */
.pf-card.pf-card--wide {
min-width: 400px; /* tu peux monter à 500 ou 600 selon ton goût */
flex: 1 1 400px; /* laisse la carte prendre plus de place dans la ligne */
}
/* Le tableau des vacances occupe toute la largeur disponible */
#schoolHolidaysTable {
width: auto;
table-layout: auto;
border-collapse: collapse;
}
.pf-card.pf-card--wide .pf-table-wrapper {
width: 100%;
overflow-x: auto;
}
/* En-têtes et cellules sans retour à la ligne */
#schoolHolidaysTable th,
#schoolHolidaysTable td {
white-space: nowrap; /* pas de retour à la ligne dans les cellules */
overflow: visible; /* pas de tronquage */
padding: 4px 8px;
text-align: center;
}
.pf-flex {
display: flex;
}
.pf-flex--wrap {
flex-wrap: wrap;
}
.pf-gap-lg {
gap: 24px;
}
.pf-card {
background: #f8fafc;
border-radius: 8px;
border: 1px solid #d9e2ec;
padding: 12px 14px;
}
.pf-card--small {
min-width: 240px;
max-width: 260px;
}
.pf-card-title {
margin: 0 0 8px;
font-size: 15px;
}
.pf-card-body {
font-size: 13px;
}
label {
font-size: 12px;
}
/* === TABLES === */
/* Appliquer un layout fixe pour harmoniser la largeur des colonnes */
#planningTable {
table-layout: fixed;
width: 100%; /* le tableau prend toute la largeur disponible */
}
/* Colonnes : texte centré et césures possibles */
#planningTable th,
#planningTable td {
text-align: center;
white-space: nowrap; /* évite les retours à la ligne sauvages */
overflow: hidden;
text-overflow: ellipsis; /* coupe avec "..." si le texte déborde */
}
/* Colonnes "Mois" et "Semaine" un peu plus étroites */
#planningTable th:nth-child(1),
#planningTable td:nth-child(1) {
width: 2%; /* Mois */
}
#planningTable th:nth-child(2),
#planningTable td:nth-child(2) {
width: 2%; /* Semaine */
}
/* Colonnes jours (Lundi à Vendredi) réparties sur une largeur raisonnable */
#planningTable th:nth-child(3),
#planningTable td:nth-child(3),
#planningTable th:nth-child(4),
#planningTable td:nth-child(4),
#planningTable th:nth-child(5),
#planningTable td:nth-child(5),
#planningTable th:nth-child(6),
#planningTable td:nth-child(6),
#planningTable th:nth-child(7),
#planningTable td:nth-child(7) {
width: 2%; /* ajustable selon ton rendu */
}
/* Colonnes de totaux (Off / Extra / Centre / Avis) un peu plus compactes */
#planningTable th:nth-child(8),
#planningTable td:nth-child(8),
#planningTable th:nth-child(9),
#planningTable td:nth-child(9),
#planningTable th:nth-child(10),
#planningTable td:nth-child(10),
#planningTable th:nth-child(11),
#planningTable td:nth-child(11) {
width: 4%;
}
.pf-table-wrapper {
max-height: 600px;
overflow: auto;
border-radius: 8px;
background: #fff;
border: 1px solid #d9e2ec;
}
.pf-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.pf-table th,
.pf-table td {
border: 1px solid #d9e2ec;
padding: 4px 6px;
text-align: center;
}
.pf-table thead th {
position: sticky;
top: 0;
background: #f0f4f8;
z-index: 1;
}
.pf-table--compact th,
.pf-table--compact td {
padding: 3px 4px;
}
/* === INPUTS === */
input[type="number"] {
width: 3.1rem;
font-size: 11px;
padding: 2px 3px;
}
/* === COLOR CODING POUR LE FAMILY CALENDAR === */
.fc-row--carole-off {
background: #ffe0e0;
}
.fc-row--bank-holiday {
background: #d9f7be;
}
/* Tableau recap vacances scolaires */
.fc-holidays-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 12px;
}
.fc-holidays-table th,
.fc-holidays-table td {
border: 1px solid #d9e2ec;
padding: 4px 6px;
text-align: left;
}
.fc-holidays-table th {
background-color: #f0f4f8;
}
/* Cellule de jour sélectionnée dans le calendrier */
.fc-day--selected {
background-color: #bde4ff !important; /* Une couleur de sélection claire, !important pour forcer le style */
cursor: pointer;
}
/* Le menu contextuel flottant */
.fc-selection-menu {
display: none; /* Caché par défaut */
position: absolute; /* Permet de le positionner près de la souris */
z-index: 100;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
.fc-selection-menu .fc-menu-section {
margin-bottom: 8px;
}
.fc-selection-menu .fc-menu-section:last-child {
margin-bottom: 0;
}
.fc-selection-menu strong {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.fc-selection-menu button {
display: block;
width: 100%;
background: none;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 8px;
margin-top: 4px;
text-align: left;
cursor: pointer;
}
.fc-selection-menu button:hover {
background-color: #f0f4f8;
border-color: #b0c4de;
}
/* --- Couleurs de base pour la légende et les jours --- */
.fc-day--school-holiday {
background-color: #e5d9f2;
}
.fc-day--public-holiday {
background-color: #e0e0e0;
}
.fc-day--off-carole {
background-color: #ffe9a7;
}
.fc-day--extra-off-carole {
background-color: #ffd59b;
}
.fc-day--has-guard {
box-sizing: border-box;
}
/* Bordure verte pour Centre */
.fc-day--has-guard.fc-day--centre {
border: 2px solid #4caf50;
}
/* Bordure bleue pour Avis */
.fc-day--has-guard.fc-day--avis {
border: 2px solid #2196f3;
}
/* --- Combinaisons avec dégradés --- */
/* Combinaison Vacances + Off Carole */
.fc-day--school-holiday.fc-day--off-carole {
background-image: linear-gradient(45deg, #e5d9f2 49%, #ffe9a7 51%);
}
/* Combinaison Vacances + Extra Off Carole */
.fc-day--school-holiday.fc-day--extra-off-carole {
background-image: linear-gradient(45deg, #e5d9f2 49%, #ffd59b 51%);
}
/* Eléments pour la légende */
.pf-legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.pf-legend-color {
width: 20px;
height: 20px;
border: 1px solid #ccc;
margin-right: 10px;
flex-shrink: 0;
}
-792
View File
@@ -1,792 +0,0 @@
/**
* family-calendar.js
* Distinction congés / modes de garde + modif/suppression locales.
*/
document.addEventListener("DOMContentLoaded", () => {
const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"];
const GUARDE_TYPES = ["CENTRE", "AVIS"];
const MODIFIABLE_TYPES = [...CONGE_TYPES, ...GUARDE_TYPES];
class FamilyCalendar {
constructor() {
this.planningBody = document.getElementById("planningBody");
this.selectionMenu = document.getElementById("selectionMenu");
this.schoolHolidaysTableBody = document.querySelector(
"#schoolHolidaysTable tbody"
);
if (!this.planningBody || !this.selectionMenu) {
console.error("planningBody ou selectionMenu manquant");
return;
}
this.isSelecting = false;
this.selectedCells = [];
this.dbEvents = [];
this.fixedEvents = [];
this.events = [];
this.menuJustOpened = false;
this.init();
window.cal = this;
}
// ================== INIT ==================
async init() {
this.setupEventListeners();
this.weeks = this.generateWeeksStructure();
this.dbEvents = this.loadDbEvents();
this.fixedEvents = await this.fetchPublicAndSchoolHolidays();
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
}
loadDbEvents() {
if (typeof serverData !== "undefined" && Array.isArray(serverData)) {
return serverData.map((evt) => ({
id: evt.id,
date: evt.event_date,
type: evt.event_type,
duration: parseFloat(evt.duration),
person_id: evt.person_id,
}));
}
return [];
}
async fetchPublicAndSchoolHolidays() {
// 1. Jours fériés (fixes)
const publicHolidays = [
{ date: "2025-11-01", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2025-11-11", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2025-12-25", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-01-01", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-04-06", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-05-01", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-05-08", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-05-14", type: "PUBLIC_HOLIDAY", duration: 1 },
{ date: "2026-05-25", type: "PUBLIC_HOLIDAY", duration: 1 },
].map((e, idx) => ({
id: `ph-${idx}`,
...e,
}));
// 2. Vacances scolaires (API)
const schoolHolidayEvents = [];
let holidayRecords = [];
try {
const response = await fetch(
"https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='2025-2026' AND zones LIKE '%Zone C%'&limit=100"
);
const schoolHolidaysData = await response.json();
holidayRecords = schoolHolidaysData.results || [];
holidayRecords.forEach((record, index) => {
let current = new Date(record.start_date);
let end = new Date(record.end_date);
while (current < end) {
const isoDate = `${current.getFullYear()}-${String(
current.getMonth() + 1
).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`;
schoolHolidayEvents.push({
id: `sh-${isoDate}-${index}`,
date: isoDate,
type: "VACANCES_SCOLAIRES",
duration: 1,
});
current.setDate(current.getDate() + 1);
}
});
// Remplir le tableau HTML des vacances
this.renderSchoolHolidaysTable(holidayRecords);
} catch (error) {
console.error("Impossible de charger les vacances scolaires.", error);
}
// 3. Retourne tous les événements "fixes"
return [...publicHolidays, ...schoolHolidayEvents];
}
generateWeeksStructure() {
const weeks = [];
const start = new Date(2025, 8, 1); // 1er septembre 2025
const end = new Date(2026, 7, 31); // 31 août 2026
let current = new Date(start);
// Remonter au lundi le plus proche <= start
while (current.getDay() !== 1) current.setDate(current.getDate() - 1);
while (current <= end) {
const monday = new Date(current);
// Cest CETTE date qui doit servir de référence pour le mois
const weekMonthDate = monday;
const weekData = {
id: `${monday.getFullYear()}-W${getWeekOfYear(monday)}`,
monthKey: `${weekMonthDate.getFullYear()}-${String(
weekMonthDate.getMonth() + 1
).padStart(2, "0")}`,
monthName: getMonthNameFr(weekMonthDate.getMonth()),
weekLabel: `W${getWeekOfYear(monday)}`,
dayDates: {},
dayFlags: {},
};
["mon", "tue", "wed", "thu", "fri"].forEach((dayKey, index) => {
const dayDate = new Date(monday);
dayDate.setDate(monday.getDate() + index);
weekData.dayDates[dayKey] = dayDate;
weekData.dayFlags[dayKey] = { eventsOnDay: [] };
});
weeks.push(weekData);
current.setDate(current.getDate() + 7);
}
// Juste pour verrouiller lordre si un jour tu modifies current ailleurs
weeks.sort((a, b) => a.dayDates.mon - b.dayDates.mon);
return weeks;
}
reprocessAndRender() {
this.reprocessEvents();
this.renderTable();
this.updateGlobalSummary();
}
reprocessEvents() {
this.weeks.forEach((w) => {
w.totals = { offCarole: 0, extraOffCarole: 0, centre: 0, avis: 0 };
Object.values(w.dayFlags).forEach((df) => (df.eventsOnDay = []));
});
this.events.forEach((evt) => {
const evtDate = new Date(evt.date + "T00:00:00");
const week = this.weeks.find(
(w) => evtDate >= w.dayDates.mon && evtDate <= w.dayDates.fri
);
if (!week) return;
const dayKey = Object.keys(week.dayDates).find(
(k) => week.dayDates[k].toDateString() === evtDate.toDateString()
);
if (dayKey) {
week.dayFlags[dayKey].eventsOnDay.push(evt);
}
const dur = parseFloat(evt.duration) || 1;
switch (evt.type) {
case "OFF_CAROLE":
week.totals.offCarole += dur;
break;
case "EXTRA_OFF_CAROLE":
week.totals.extraOffCarole += dur;
break;
case "CENTRE":
week.totals.centre += dur;
break;
case "AVIS":
week.totals.avis += dur;
break;
}
});
}
updateGlobalSummary() {
const summaryDiv = document.getElementById("globalSummary");
if (!summaryDiv) return;
// On filtre uniquement sur les événements "DB" pour le récap (pas les vacances/fériés)
const allEvents = this.dbEvents || [];
const totalOff = allEvents
.filter((e) => e.type === "OFF_CAROLE")
.reduce((sum, e) => sum + Number(e.duration || 1), 0);
const totalExtraOff = allEvents
.filter((e) => e.type === "EXTRA_OFF_CAROLE")
.reduce((sum, e) => sum + Number(e.duration || 1), 0);
summaryDiv.innerHTML = `
<p><strong>Off Carole :</strong> ${totalOff} jours</p>
<p><strong>Extra Off Carole :</strong> ${totalExtraOff} jours</p>
`;
}
renderSchoolHolidaysTable(holidayRecords) {
if (!this.schoolHolidaysTableBody) return;
this.schoolHolidaysTableBody.innerHTML = "";
const uniqueHolidays = new Map();
holidayRecords.forEach((r) =>
uniqueHolidays.set(`${r.start_date}|${r.end_date}`, r)
);
[...uniqueHolidays.values()]
.sort((a, b) => new Date(a.start_date) - new Date(b.start_date))
.forEach((record) => {
const tr = document.createElement("tr");
const startDate = new Date(record.start_date);
const endDate = new Date(record.end_date);
tr.innerHTML = `
<td>${record.description}</td>
<td>${startDate.toLocaleDateString("fr-FR")}</td>
<td>${endDate.toLocaleDateString("fr-FR")}</td>
<td>${record.zones}</td>
`;
this.schoolHolidaysTableBody.appendChild(tr);
});
}
renderTable() {
this.planningBody.innerHTML = "";
const monthSpans = this.weeks.reduce(
(acc, w) => ({ ...acc, [w.monthKey]: (acc[w.monthKey] || 0) + 1 }),
{}
);
const monthRowRendered = {};
const formatTotal = (total) =>
total > 0 ? (Number.isInteger(total) ? total : total.toFixed(1)) : "";
this.weeks.forEach((week) => {
const tr = document.createElement("tr");
if (!monthRowRendered[week.monthKey]) {
monthRowRendered[week.monthKey] = true;
const tdMonth = document.createElement("td");
tdMonth.rowSpan = monthSpans[week.monthKey] || 1;
tdMonth.textContent = week.monthName;
tr.appendChild(tdMonth);
}
const tdWeek = document.createElement("td");
tdWeek.textContent = week.weekLabel;
tr.appendChild(tdWeek);
["mon", "tue", "wed", "thu", "fri"].forEach((dayKey) => {
const td = document.createElement("td");
const dayDate = week.dayDates[dayKey];
td.dataset.date = `${dayDate.getFullYear()}-${String(
dayDate.getMonth() + 1
).padStart(2, "0")}-${String(dayDate.getDate()).padStart(2, "0")}`;
td.textContent = `${String(dayDate.getDate()).padStart(
2,
"0"
)}/${String(dayDate.getMonth() + 1).padStart(2, "0")}`;
td.className = "";
let hasGarde = false;
let gardeType = null;
week.dayFlags[dayKey].eventsOnDay.forEach((evt) => {
const classMap = {
VACANCES_SCOLAIRES: "fc-day--school-holiday",
PUBLIC_HOLIDAY: "fc-day--public-holiday",
OFF_CAROLE: "fc-day--off-carole",
EXTRA_OFF_CAROLE: "fc-day--extra-off-carole",
};
if (classMap[evt.type]) {
td.classList.add(classMap[evt.type]);
}
if (GUARDE_TYPES.includes(evt.type)) {
hasGarde = true;
gardeType = evt.type;
}
});
if (hasGarde) {
td.classList.add("fc-day--has-guard");
if (gardeType === "CENTRE") td.classList.add("fc-day--centre");
if (gardeType === "AVIS") td.classList.add("fc-day--avis");
}
tr.appendChild(td);
});
const tdOff = document.createElement("td");
tdOff.textContent = formatTotal(week.totals.offCarole);
tr.appendChild(tdOff);
const tdExtra = document.createElement("td");
tdExtra.textContent = formatTotal(week.totals.extraOffCarole);
tr.appendChild(tdExtra);
const tdCentre = document.createElement("td");
tdCentre.textContent = formatTotal(week.totals.centre);
tr.appendChild(tdCentre);
const tdAvis = document.createElement("td");
tdAvis.textContent = formatTotal(week.totals.avis);
tr.appendChild(tdAvis);
this.planningBody.appendChild(tr);
});
}
// ================== INTERACTIONS ==================
setupEventListeners() {
this.planningBody.addEventListener("mousedown", (e) =>
this.handleMouseDown(e)
);
document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
document.addEventListener("mouseup", (e) => this.handleMouseUp(e));
document.addEventListener(
"click",
(e) => this.handleClickOutsideMenu(e),
true
);
this.selectionMenu.addEventListener("click", (e) =>
this.handleMenuClick(e)
);
}
handleMouseDown(e) {
const cell = e.target.closest("td[data-date]");
if (!cell) return;
e.preventDefault();
this.clearSelection();
this.isSelecting = true;
cell.classList.add("fc-day--selected");
this.selectedCells.push(cell);
}
handleMouseMove(e) {
if (!this.isSelecting) return;
const cell = e.target.closest("td[data-date]");
if (cell && !this.selectedCells.includes(cell)) {
cell.classList.add("fc-day--selected");
this.selectedCells.push(cell);
}
}
handleMouseUp(e) {
if (!this.isSelecting) return;
this.isSelecting = false;
if (this.selectedCells.length === 0) return;
if (this.selectedCells.length === 1) {
const date = this.selectedCells[0].dataset.date;
const eventsOnDay = this.events.filter(
(evt) => evt.date === date && MODIFIABLE_TYPES.includes(evt.type)
);
const conge = eventsOnDay.find((e) => CONGE_TYPES.includes(e.type));
const garde = eventsOnDay.find((e) => GUARDE_TYPES.includes(e.type));
if (!conge && !garde) {
this.showAddMenu(e);
} else {
this.showEditMenuForDay(e, { conge, garde, date });
}
} else {
// Multi-jours : on reste en mode ajout, avec contrôles plus tard
this.showAddMenu(e);
}
}
handleClickOutsideMenu(e) {
if (this.menuJustOpened) {
this.menuJustOpened = false;
return;
}
if (
this.selectionMenu.style.display === "block" &&
!this.selectionMenu.contains(e.target)
) {
this.clearSelection();
}
}
clearSelection() {
this.selectionMenu.style.display = "none";
this.selectedCells.forEach((cell) =>
cell.classList.remove("fc-day--selected")
);
this.selectedCells = [];
}
showAddMenu(e) {
this.selectionMenu.innerHTML = `
<div class="fc-menu-section"><strong>Ajouter</strong></div>
<button data-action="add" data-type="OFF_CAROLE" data-person="Carole">Off Carole</button>
<button data-action="add" data-type="EXTRA_OFF_CAROLE" data-person="Carole">Extra Off Carole</button>
<div class="fc-menu-section"><strong>Mode de Garde</strong></div>
<button data-action="add" data-type="CENTRE">Centre</button>
<button data-action="add" data-type="AVIS">Avis</button>
`;
this.positionAndShowMenu(e);
}
showEditMenuForDay(e, { conge, garde, date }) {
console.log(
"[EDIT MENU] pour date",
date,
"conge =",
conge,
"garde =",
garde
);
let congeSection = "";
if (conge) {
const oppositeConge =
conge.type === "OFF_CAROLE" ? "EXTRA_OFF_CAROLE" : "OFF_CAROLE";
congeSection = `
<div class="fc-menu-section"><strong>Congé Carole</strong></div>
<button data-action="update" data-event-id="${
conge.id
}" data-new-type="${oppositeConge}">
Remplacer par ${oppositeConge.replace(/_/g, " ")}
</button>
<button data-action="delete" data-event-id="${conge.id}">
Supprimer le congé
</button>
`;
} else {
congeSection = `
<div class="fc-menu-section"><strong>Ajouter un congé</strong></div>
<button data-action="add-single" data-type="OFF_CAROLE" data-date="${date}" data-person="Carole">
Off Carole
</button>
<button data-action="add-single" data-type="EXTRA_OFF_CAROLE" data-date="${date}" data-person="Carole">
Extra Off Carole
</button>
`;
}
let gardeSection = "";
if (garde) {
const oppositeGarde = garde.type === "CENTRE" ? "AVIS" : "CENTRE";
gardeSection = `
<div class="fc-menu-section"><strong>Mode de garde</strong></div>
<button data-action="update" data-event-id="${garde.id}" data-new-type="${oppositeGarde}">
Remplacer par ${oppositeGarde}
</button>
<button data-action="delete" data-event-id="${garde.id}">
Supprimer le mode de garde
</button>
`;
} else {
gardeSection = `
<div class="fc-menu-section"><strong>Ajouter mode de garde</strong></div>
<button data-action="add-single" data-type="CENTRE" data-date="${date}">
Centre
</button>
<button data-action="add-single" data-type="AVIS" data-date="${date}">
Avis
</button>
`;
}
this.selectionMenu.innerHTML = congeSection + gardeSection;
this.positionAndShowMenu(e);
}
positionAndShowMenu(e) {
this.selectionMenu.style.display = "block";
this.selectionMenu.style.left = `${e.pageX + 5}px`;
this.selectionMenu.style.top = `${e.pageY + 5}px`;
this.menuJustOpened = true;
}
async handleMenuClick(e) {
const button = e.target.closest("button[data-action]");
if (!button) return;
const { action, eventId, newType, type, person, date } = button.dataset;
// === AJOUT MULTI-JOURS (sélection de plusieurs cellules) ===
if (action === "add") {
const selectedDates = this.selectedCells.map((c) => c.dataset.date);
const existingOnDates = this.dbEvents.filter((evt) =>
selectedDates.includes(evt.date)
);
// Contrôles métier : pas de double congé / pas de double mode de garde
if (CONGE_TYPES.includes(type)) {
const hasCongeConflict = existingOnDates.some((evt) =>
CONGE_TYPES.includes(evt.type)
);
if (hasCongeConflict) {
alert(
"Impossible d'ajouter un congé : au moins une date a déjà un congé (Off/Extra Off)."
);
this.clearSelection();
return;
}
} else if (GUARDE_TYPES.includes(type)) {
const hasGardeConflict = existingOnDates.some((evt) =>
GUARDE_TYPES.includes(evt.type)
);
if (hasGardeConflict) {
alert(
"Impossible d'ajouter un mode de garde : au moins une date a déjà Centre/Avis."
);
this.clearSelection();
return;
}
}
const newEvents = this.selectedCells.map((cell) => ({
date: cell.dataset.date,
type,
person,
duration: 1.0,
}));
this.clearSelection();
try {
const response = await fetch("/api/save-events.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newEvents),
});
if (!response.ok) {
throw new Error("Erreur HTTP " + response.status);
}
// Resync DB events
const resEvents = await fetch("/api/get-events.php");
if (!resEvents.ok) {
throw new Error("Erreur lors du rechargement des événements.");
}
const dataEvents = await resEvents.json();
this.dbEvents = dataEvents.events || [];
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
} catch (err) {
console.error(err);
alert("Erreur lors de l'ajout.");
}
return;
}
// === AJOUT SUR UNE SEULE DATE (menu combiné d'un jour) ===
if (action === "add-single") {
const targetDate = date;
const existingOnDate = this.dbEvents.filter(
(evt) => evt.date === targetDate
);
if (CONGE_TYPES.includes(type)) {
const hasCongeConflict = existingOnDate.some((evt) =>
CONGE_TYPES.includes(evt.type)
);
if (hasCongeConflict) {
alert("Un congé existe déjà ce jour-là.");
this.clearSelection();
return;
}
} else if (GUARDE_TYPES.includes(type)) {
const hasGardeConflict = existingOnDate.some((evt) =>
GUARDE_TYPES.includes(evt.type)
);
if (hasGardeConflict) {
alert("Un mode de garde existe déjà ce jour-là.");
this.clearSelection();
return;
}
}
const newEvent = {
date: targetDate,
type,
person,
duration: 1.0,
};
this.clearSelection();
try {
const response = await fetch("/api/save-events.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([newEvent]),
});
if (!response.ok) {
throw new Error("Erreur HTTP " + response.status);
}
// Resync DB events
const resEvents = await fetch("/api/get-events.php");
if (!resEvents.ok) {
throw new Error("Erreur lors du rechargement des événements.");
}
const dataEvents = await resEvents.json();
this.dbEvents = dataEvents.events || [];
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
} catch (err) {
console.error(err);
alert("Erreur lors de l'ajout.");
}
return;
}
// === SUPPRESSION ===
if (action === "delete") {
if (!eventId) {
console.warn("Bouton delete sans eventId, action ignorée.");
this.clearSelection();
return;
}
this.clearSelection();
try {
const response = await fetch("/api/manage-event.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", event_id: eventId }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(
errData.message || "Erreur HTTP " + response.status
);
}
// Resync DB events
const resEvents = await fetch("/api/get-events.php");
if (!resEvents.ok) {
throw new Error("Erreur lors du rechargement des événements.");
}
const dataEvents = await resEvents.json();
this.dbEvents = dataEvents.events || [];
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
} catch (err) {
console.error(err);
alert("Erreur lors de la suppression : " + err.message);
}
return;
}
// === MODIFICATION (change OFF <-> EXTRA ou CENTRE <-> AVIS) ===
if (action === "update") {
console.log("[UPDATE] click sur bouton update", { eventId, newType });
if (!eventId) {
console.warn("Bouton update sans eventId, action ignorée.");
this.clearSelection();
return;
}
this.clearSelection();
try {
const response = await fetch("/api/manage-event.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "update",
event_id: eventId,
new_type: newType,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(
errData.message || "Erreur HTTP " + response.status
);
}
// Resync DB events
const resEvents = await fetch("/api/get-events.php");
if (!resEvents.ok) {
throw new Error("Erreur lors du rechargement des événements.");
}
const dataEvents = await resEvents.json();
this.dbEvents = dataEvents.events || [];
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
} catch (err) {
console.error("[UPDATE] Erreur lors de la modification :", err);
alert("Erreur lors de la modification : " + err.message);
}
return;
}
}
async manageEvent(payload) {
try {
const response = await fetch("/api/manage-event.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.message || "Erreur HTTP " + response.status);
}
const resEvents = await fetch("/api/get-events.php");
if (!resEvents.ok) {
throw new Error("Erreur lors du rechargement des événements.");
}
const dataEvents = await resEvents.json();
this.dbEvents = dataEvents.events || [];
this.events = [...this.dbEvents, ...this.fixedEvents];
this.reprocessAndRender();
} catch (error) {
console.error("Erreur manageEvent :", error);
alert("Erreur: " + error.message);
}
}
}
function getWeekOfYear(date) {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
);
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
function getMonthNameFr(monthIndex) {
return (
[
"Janvier",
"Fevrier",
"Mars",
"Avril",
"Mai",
"Juin",
"Juillet",
"Aout",
"Septembre",
"Octobre",
"Novembre",
"Decembre",
][monthIndex] || ""
);
}
new FamilyCalendar();
});
+88
View File
@@ -0,0 +1,88 @@
<?php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
require_login('/login.php');
// Gestion de l'onglet actif (par défaut 'suivi')
$tab = $_GET['tab'] ?? 'suivi';
$pageTitle = tr('budget_page_title');
$activePage = "budget";
$pageCss = "/modules/budget/budget.css";
require __DIR__ . '/header.php';
?>
<div class="pf-container">
<div class="pf-hero" style="text-align: center; margin-bottom: 30px;">
<h1 style="margin-bottom: 20px;"><?= tr('budget_main_header') ?></h1>
<nav class="budget-tabs-container">
<a href="?tab=suivi" class="tab-item <?= $tab == 'suivi' ? 'active' : '' ?>">
<span class="tab-icon">🗓️</span>
<span><?= tr('budget_tab_tracking') ?></span>
</a>
<a href="?tab=budget_prev" class="tab-item <?= $tab == 'budget_prev' ? 'active' : '' ?>">
<span class="tab-icon">🎯</span>
<span><?= tr('budget_tab_prev') ?></span>
</a>
<a href="?tab=epargne" class="tab-item <?= $tab == 'epargne' ? 'active' : '' ?>">
<span class="tab-icon">🐷</span>
<span><?= tr('budget_tab_savings') ?></span>
</a>
<a href="?tab=recap" class="tab-item <?= $tab == 'recap' ? 'active' : '' ?>">
<span class="tab-icon">📊</span>
<span><?= tr('budget_tab_recap') ?></span>
</a>
</nav>
</div>
<section class="pf-section">
<?php
// ROUTAGE DYNAMIQUE
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
if (in_array($tab, $allowedTabs)) {
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
if (file_exists($viewPath)) {
include $viewPath;
} else {
echo "<div style='text-align:center; padding:50px; color:#ef4444;'>";
echo "<h3>" . tr('budget_err_file_not_found') . "</h3>";
echo "<p>" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "</p>";
echo "</div>";
}
} else {
echo "<p>" . tr('budget_err_not_authorized') . "</p>";
}
?>
</section>
</div>
<script>
// --- AUTO-SCROLL DES ONGLETS MOBILES ---
document.addEventListener('DOMContentLoaded', () => {
const tabsContainer = document.querySelector('.budget-tabs-container');
const activeTab = document.querySelector('.tab-item.active');
// Si on a bien un conteneur d'onglets ET un onglet actif
if (tabsContainer && activeTab) {
// On vérifie si le conteneur a un scroll horizontal possible
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
// On calcule la position pour centrer l'onglet à l'écran
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
// On fait glisser le menu doucement
tabsContainer.scrollTo({
left: scrollPos,
behavior: 'smooth'
});
}
}
});
</script>
<?php require __DIR__ . '/footer.php'; ?>
+91
View File
@@ -0,0 +1,91 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'Calendrier iOS — HouseHub';
$activePage = 'calendar-ios';
$mainClass = 'pf-calendar-ios-page';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/calendar-ios/assets/calendar-ios.css">
<div class="pf-container ios-calendar-wrap">
<section class="pf-panel-card">
<div class="ios-calendar-head">
<h1>📱 <?= tr('menu_calendar_ios') ?></h1>
<div class="ios-calendar-head-actions">
<button type="button" class="ios-agenda-btn ios-agenda-btn--ghost" id="ios-sync-btn">Synchroniser</button>
</div>
</div>
<p class="ios-agenda-intro">Les événements viennent de HouseHub après synchro iCloud. <strong>Synchroniser</strong> met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.</p>
<details class="ios-cal-prefs-details" id="ios-cal-prefs-details">
<summary class="ios-cal-prefs-summary">Calendriers affichés et couleurs</summary>
<p class="ios-cal-prefs-help">Décoche un calendrier pour le masquer dans lagenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).</p>
<div id="ios-calendar-prefs-rows" class="ios-cal-prefs-rows"></div>
<p id="ios-calendar-prefs-msg" class="ios-cal-prefs-msg" role="status"></p>
<button type="button" class="pf-btn" id="ios-calendar-prefs-save">Enregistrer affichage &amp; couleurs</button>
</details>
</section>
<section class="pf-panel-card ios-agenda-card">
<div class="ios-agenda-toolbar">
<div class="ios-agenda-title" id="ios-agenda-title" aria-live="polite">—</div>
<div class="ios-agenda-segments" role="tablist">
<button type="button" class="ios-seg" data-ios-view="day" role="tab">Jour</button>
<button type="button" class="ios-seg ios-seg--active" data-ios-view="week" role="tab">Semaine</button>
<button type="button" class="ios-seg" data-ios-view="month" role="tab">Mois</button>
</div>
<div class="ios-agenda-nav">
<button type="button" class="ios-agenda-btn" id="ios-agenda-prev" aria-label="Période précédente"></button>
<button type="button" class="ios-agenda-btn" id="ios-agenda-next" aria-label="Période suivante"></button>
<button type="button" class="ios-agenda-today" id="ios-agenda-today">Aujourd'hui</button>
<button type="button" class="ios-agenda-add" id="ios-agenda-add" title="Nouvel événement">+</button>
<button type="button" class="ios-agenda-listlink" id="ios-view-list">Liste</button>
</div>
</div>
<div id="ios-agenda-mount" class="ios-agenda-mount"></div>
</section>
<section class="pf-panel-card" id="ios-edit-panel">
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
<form id="ios-event-form" class="ios-form-grid">
<input type="hidden" id="ios-event-id">
<div class="pf-form-group">
<label class="pf-label">Titre</label>
<input class="pf-input" id="ios-title" required>
</div>
<div class="pf-form-group">
<label class="pf-label">Lieu</label>
<input class="pf-input" id="ios-location">
</div>
<div class="pf-form-group">
<label class="pf-label">Début</label>
<input class="pf-input" id="ios-start" type="datetime-local" required>
</div>
<div class="pf-form-group">
<label class="pf-label">Fin</label>
<input class="pf-input" id="ios-end" type="datetime-local" required>
</div>
<div class="pf-form-group ios-form-full">
<label class="pf-label">Description</label>
<textarea class="pf-input" id="ios-description"></textarea>
</div>
<div class="ios-form-full ios-form-actions">
<button class="pf-btn" type="submit">Enregistrer</button>
<button class="pf-btn btn-secondary" type="button" id="ios-form-reset">Réinitialiser</button>
</div>
</form>
</section>
<section class="pf-panel-card" id="ios-list-section" style="display:none">
<h2 class="pf-card-h2">Événements (liste)</h2>
<div id="ios-sync-status" class="ios-agenda-intro"></div>
<div id="ios-events-list" class="ios-events-list"></div>
</section>
</div>
<script src="/modules/calendar-ios/assets/calendar-ios.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+20
View File
@@ -0,0 +1,20 @@
<?php
require_once dirname(__DIR__) . '/includes/meta_db.php';
$rows = $meta_pdo->query("SELECT user_id FROM user_calendar_integrations WHERE provider='icloud_caldav' AND status='connected'")->fetchAll();
foreach ($rows as $row) {
$userId = (int)$row['user_id'];
$url = 'http://localhost/modules/calendar-ios/api.php?action=sync';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-Requested-With: XMLHttpRequest',
// Le cron réel devra utiliser un mécanisme d'auth/session technique.
],
]);
curl_exec($ch);
curl_close($ch);
}
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* Cron: Todo daily reminder notifications via Discord webhook
* Runs every minute — sends Discord reminder when due_time is reached.
* Repeats every day at the same time (resets at midnight via notified_date).
* Usage: php /opt/container/househub/cron/todo_notify.php
*/
date_default_timezone_set('Europe/Paris');
$DB_HOST = getenv('DB_HOST') ?: 'househub-db';
$DB_USER = getenv('DB_USER') ?: 'househub';
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
// Load .env if running from CLI outside container
$env_file = dirname(__DIR__) . '/.env';
if (file_exists($env_file)) {
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (strpos($line, '=') !== false && $line[0] !== '#') {
[$k, $v] = explode('=', $line, 2);
$k = trim($k); $v = trim($v);
if (!getenv($k)) putenv("$k=$v");
}
}
$DB_HOST = getenv('DB_HOST') ?: $DB_HOST;
$DB_USER = getenv('DB_USER') ?: $DB_USER;
$DB_PASS = getenv('DB_PASS') ?: $DB_PASS;
$DB_ROOT = getenv('DB_ROOT_PASS') ?: $DB_ROOT;
}
// Get list of families from meta DB
try {
$meta = new PDO(
"mysql:host=$DB_HOST;dbname=househub_meta;charset=utf8mb4",
'root', $DB_ROOT,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$families = $meta->query("SELECT id FROM families")->fetchAll(PDO::FETCH_COLUMN);
} catch (Exception $e) {
exit("Cannot connect to meta DB: " . $e->getMessage() . "\n");
}
foreach ($families as $family_id) {
$db_name = 'househub_f' . $family_id;
try {
$pdo = new PDO(
"mysql:host=$DB_HOST;dbname=$db_name;charset=utf8mb4",
$DB_USER, $DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
} catch (Exception $e) {
continue;
}
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
$ws->execute();
$webhook = $ws->fetchColumn();
if (!$webhook) continue;
// Daily reminder: exact HH:MM match (same logic as the original todo app)
$now = date('H:i');
$stmt = $pdo->prepare("
SELECT t.id, t.title, t.notes, t.due_time, t.priority,
l.name AS list_name, l.icon AS list_icon, l.color AS list_color
FROM pf_todos t
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
WHERE t.due_time IS NOT NULL
AND TIME_FORMAT(t.due_time, '%H:%i') = ?
AND t.done = 0
");
$stmt->execute([$now]);
$priColors = ['high' => 0xef4444, 'medium' => 0xf59e0b, 'low' => 0x22c55e, 'none' => 0x3b82f6];
$priLabels = ['high' => '🔴 Haute', 'medium' => '🟡 Moyenne', 'low' => '🟢 Basse', 'none' => null];
foreach ($stmt->fetchAll() as $t) {
$time = substr($t['due_time'], 0, 5);
$priority = $t['priority'] ?? 'none';
$color = $priColors[$priority] ?? 0x3b82f6;
$fields = [];
if ($t['list_name']) {
$fields[] = ['name' => 'Liste', 'value' => $t['list_icon'] . ' ' . $t['list_name'], 'inline' => true];
}
if ($priLabels[$priority]) {
$fields[] = ['name' => 'Priorité', 'value' => $priLabels[$priority], 'inline' => true];
}
$fields[] = ['name' => 'Heure', 'value' => '⏰ ' . $time, 'inline' => true];
$embed = [
'title' => '🔔 Rappel : ' . $t['title'],
'color' => $color,
'fields' => $fields,
'footer' => ['text' => 'HouseHub Todo'],
];
if (!empty($t['notes'])) {
$embed['description'] = $t['notes'];
}
$payload = ['username' => 'HouseHub Todo', 'embeds' => [$embed]];
$ch = curl_init($webhook);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
curl_exec($ch);
curl_close($ch);
}
}
+127
View File
@@ -0,0 +1,127 @@
/* ════════════════════════════════════════════════════════════════════
dark-mode.css — Dark mode overrides (all modules)
Chargé en dernier dans le head pour garantir la cascade.
════════════════════════════════════════════════════════════════════ */
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
[data-theme="dark"] body[data-page="home"] .pf-module-card {
background: rgba(22,27,34,.92) !important;
border: 1px solid var(--border-light) !important;
box-shadow: 0 4px 15px rgba(0,0,0,.4) !important;
}
[data-theme="dark"] body[data-page="home"] .pf-module-card:hover {
border-color: var(--primary) !important;
}
[data-theme="dark"] body[data-page="home"] .pf-card-title { color: var(--text-main); }
[data-theme="dark"] body[data-page="home"] .pf-card-desc { color: var(--text-muted); }
[data-theme="dark"] body[data-page="home"] .pf-card-cta { color: var(--primary); }
/* ─── SHARED / GLOBAL PATTERNS ──────────────────────────────────────────────── */
[data-theme="dark"] .pf-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-table { background: var(--bg-panel); color: var(--text-main); }
[data-theme="dark"] .pf-table thead th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-table tbody td { background: var(--bg-panel); color: var(--text-main); border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .pf-table tbody tr:hover td { background: rgba(255,255,255,.03); }
[data-theme="dark"] .pf-table tfoot,
[data-theme="dark"] .pf-table tfoot td { background: #1c2128; color: var(--text-main); border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel) !important; border: 1px solid var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-modal-title { color: var(--text-main) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-input,
[data-theme="dark"] .pf-filter-select,
[data-theme="dark"] .hol-year-select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-input::placeholder { color: #484f58 !important; }
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.06) !important; color: var(--text-main) !important; }
/* ─── BUDGET MODULE ─────────────────────────────────────────────────────────── */
[data-theme="dark"] .budget-view { color: var(--text-main); }
[data-theme="dark"] .budget-view h2,
[data-theme="dark"] .view-header h2 { color: var(--text-main) !important; }
[data-theme="dark"] .budget-tabs-container { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .tab-item { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .tab-item:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
[data-theme="dark"] .tab-item.active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .owner-tabs { background: var(--bg-page); border-color: var(--border-light); }
[data-theme="dark"] .owner-tab { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .owner-tab.active { background: var(--primary) !important; color: #fff !important; }
[data-theme="dark"] .month-header-container { color: var(--text-main); }
/* sticky col: uses var(--bg-panel) already but th.sticky-col uses hardcoded #f8fafc */
[data-theme="dark"] th.sticky-col { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] .sticky-col { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .month-actions .btn-icon-small:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .savings-table .row-extres td { background: rgba(245,158,11,.08) !important; }
[data-theme="dark"] .nens-title.theme-pol { background: rgba(30,58,138,.2) !important; color: #93c5fd !important; border-color: #3b82f6 !important; }
[data-theme="dark"] .nens-title.theme-pep { background: rgba(20,83,45,.2) !important; color: #86efac !important; border-color: #22c55e !important; }
[data-theme="dark"] .nens-table.theme-pol thead th { background: rgba(30,58,138,.15) !important; color: #93c5fd !important; }
[data-theme="dark"] .nens-table.theme-pep thead th { background: rgba(20,83,45,.15) !important; color: #86efac !important; }
/* Specific input/selects in budget */
[data-theme="dark"] .budget-view input,
[data-theme="dark"] .budget-view select,
[data-theme="dark"] .budget-view textarea { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
/* sum selected highlight */
[data-theme="dark"] .sum-selected { background: rgba(59,130,246,.1) !important; }
/* ─── FAMILY CALENDAR MODULE ────────────────────────────────────────────────── */
[data-theme="dark"] .pf-family-calendar { color: var(--text-main); }
[data-theme="dark"] .fc-header-row { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .fc-view-controls { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-view-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-view-button--active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .fc-month-nav-row { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .fc-month-nav-row h3 { color: var(--text-main) !important; }
[data-theme="dark"] .fc-nav-button { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-nav-button:hover { background: rgba(255,255,255,.06) !important; }
/* Calendar table — lots of !important in source */
[data-theme="dark"] .fc-month-table { border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table th { background: #1c2128 !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-month-table td { background: var(--bg-panel) !important; border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] .fc-month-table td:hover:not(.fc-day--other-month) { background: rgba(255,255,255,.04) !important; }
[data-theme="dark"] .fc-day--other-month { background: var(--bg-page) !important; color: var(--text-muted) !important; }
[data-theme="dark"] .fc-month-calendar-wrapper,
[data-theme="dark"] .fc-month-balances { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-minimal-balance-card { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-min-chip { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .fc-burn-alert { background: rgba(239,68,68,.1) !important; color: #f87171 !important; }
/* Planning table (vue planning) */
[data-theme="dark"] #planningTable-wrapper { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] #planningTable thead th { background: #1c2128 !important; color: var(--text-muted) !important; }
[data-theme="dark"] #planningTable tbody td { border-color: rgba(48,54,61,.5) !important; }
[data-theme="dark"] #planningTable tbody td.col-sticky-mois,
[data-theme="dark"] #planningTable tbody td.col-sticky-sem { background: var(--bg-panel) !important; }
[data-theme="dark"] .col-month { background: var(--bg-panel) !important; color: var(--text-main) !important; }
[data-theme="dark"] .fc-week-nav-controls { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-btn-icon-text:hover { background: rgba(255,255,255,.06) !important; }
/* ─── HOLIDAYS MODULE ───────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-holidays { color: var(--text-main); }
[data-theme="dark"] .pf-holidays__titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-main-title { color: var(--text-main) !important; }
[data-theme="dark"] .hol-idea-card { background: var(--bg-panel) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; box-shadow: var(--shadow) !important; }
[data-theme="dark"] .hol-idea-card:hover { border-color: var(--primary) !important; }
[data-theme="dark"] .hol-idea-card input,
[data-theme="dark"] .hol-idea-card textarea,
[data-theme="dark"] .hol-idea-card select { background: #1c2128 !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .hol-filters-row { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .hol-map-toggle:hover { background: rgba(255,255,255,.06) !important; }
[data-theme="dark"] .hol-add-btn { background: var(--primary) !important; color: #fff !important; }
/* ─── GIFT-LIST MODULE ──────────────────────────────────────────────────────── */
[data-theme="dark"] .pf-gift-list { color: var(--text-main); }
[data-theme="dark"] .pf-gift-list h1 { color: var(--text-main) !important; }
[data-theme="dark"] .cl-titlebar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .cl-view-btn { background: var(--bg-panel) !important; color: var(--text-muted) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .cl-view-btn.is-active { background: rgba(59,130,246,.15) !important; color: var(--primary) !important; border-color: rgba(59,130,246,.3) !important; }
[data-theme="dark"] .pf-filter-bar { background: var(--bg-page) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-filter-select { background: #1c2128 !important; color: var(--text-main) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-section { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-header { background: #1c2128 !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-child-header h3 { color: var(--text-main) !important; }
[data-theme="dark"] .pf-child-totals-bar { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-summary-pill { background: var(--bg-page) !important; border-color: var(--border-light) !important; color: var(--text-main) !important; }
[data-theme="dark"] .pf-gift-card-compact { background: var(--bg-panel) !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .pf-gift-card-compact:hover { border-color: var(--primary) !important; }
[data-theme="dark"] .pf-gift-title { color: var(--text-main) !important; }
[data-theme="dark"] .pf-gift-footer { border-color: var(--border-light) !important; }
+58
View File
@@ -0,0 +1,58 @@
services:
househub:
build: .
container_name: househub
restart: unless-stopped
depends_on:
househub-db:
condition: service_healthy
environment:
DB_HOST: househub-db
DB_NAME: ${DB_NAME:-househub}
DB_USER: ${DB_USER:-househub}
DB_PASS: ${DB_PASS:-changeme}
# Clé applicative pour chiffrer les secrets (iCloud, etc.). Optionnel si absent : fichier data/.hh_app_secret ou dérivation DB.
APP_SECRET_KEY: ${APP_SECRET_KEY:-}
volumes:
- househub_uploads:/uploads
- /opt/container/househub:/var/www/html
networks:
- househub_net
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.househub.rule=Host(`househub.nas.percolouco.com`)"
- "traefik.http.routers.househub.entrypoints=websecure"
- "traefik.http.routers.househub.tls=true"
- "traefik.http.routers.househub.tls.certresolver=letsencrypt"
- "traefik.http.services.househub.loadbalancer.server.port=80"
househub-db:
image: mariadb:11
container_name: househub-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootchangeme}
MYSQL_DATABASE: househub_meta
MYSQL_USER: ${DB_USER:-househub}
MYSQL_PASSWORD: ${DB_PASS:-changeme}
volumes:
- househub_db_data:/var/lib/mysql
- ./docker/init:/docker-entrypoint-initdb.d:ro
networks:
- househub_net
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
volumes:
househub_db_data:
househub_uploads:
networks:
househub_net:
proxy:
external: true
+10
View File
@@ -0,0 +1,10 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
<Directory /var/www/html>
AllowOverride All
Require all granted
Options -Indexes
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
+47
View File
@@ -0,0 +1,47 @@
-- Initialisation HouseHub — Meta DB + permissions
-- Exécuté automatiquement au premier démarrage MariaDB
USE househub_meta;
CREATE TABLE IF NOT EXISTS families (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
db_name VARCHAR(64) NOT NULL UNIQUE,
invite_code VARCHAR(32) NOT NULL UNIQUE,
is_active TINYINT(1) DEFAULT 1,
enabled_modules JSON DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
family_id INT DEFAULT NULL,
is_admin TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
lang VARCHAR(5) DEFAULT 'fr',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (family_id) REFERENCES families(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
username VARCHAR(255) NOT NULL,
secret_encrypted TEXT NOT NULL,
dav_principal_url VARCHAR(1024) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider),
CONSTRAINT fk_calendar_integration_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Donner au user applicatif le droit de créer des DBs famille
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
FLUSH PRIVILEGES;
+5
View File
@@ -0,0 +1,5 @@
upload_max_filesize = 300M
post_max_size = 310M
memory_limit = 512M
max_execution_time = 120
max_input_time = 120
+498
View File
@@ -0,0 +1,498 @@
-- HouseHub — Schéma MySQL
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ─── Utilisateurs ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Familles ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Initialisation de la ligne unique par défaut
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '', 'C');
-- ─── Personnes ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_people (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Calendrier familial ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_date DATE NOT NULL,
event_type VARCHAR(50) NOT NULL,
person_id INT NOT NULL,
duration DECIMAL(4,2) DEFAULT 1.0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_leaves (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL,
leave_date DATE NOT NULL,
duration DECIMAL(4,2) DEFAULT 1.0,
UNIQUE KEY uq_leave (person_id, leave_type, leave_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_leave_balances (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL,
initial_balance DECIMAL(6,2) DEFAULT 0,
balance_year INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_leave_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL,
leave_type VARCHAR(50) NOT NULL,
snapshot_date DATE NOT NULL,
remaining_balance DECIMAL(6,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_person_leave_meta (
id INT AUTO_INCREMENT PRIMARY KEY,
person_id INT NOT NULL UNIQUE,
anniversary_date DATE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_calendar_weeks (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
week_iso_year INT NOT NULL,
week_iso_number INT NOT NULL,
week_label VARCHAR(20),
month INT,
month_name VARCHAR(20),
week_start_date DATE,
mon_date DATE, tue_date DATE, wed_date DATE,
thu_date DATE, fri_date DATE, sat_date DATE, sun_date DATE,
UNIQUE KEY uq_week (year, week_iso_year, week_iso_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Budget ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_budget_items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
category VARCHAR(100),
type VARCHAR(50),
payment_day INT DEFAULT NULL,
is_estimate TINYINT(1) DEFAULT 0,
reg_month VARCHAR(7) DEFAULT NULL,
mapping_keywords TEXT DEFAULT NULL,
holiday_id INT DEFAULT NULL,
is_checked TINYINT(1) DEFAULT 0,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_expenses (
id INT AUTO_INCREMENT PRIMARY KEY,
date_exp DATE NOT NULL,
gestion_month VARCHAR(10) NOT NULL,
category VARCHAR(100),
label VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0,
import_ref VARCHAR(255) DEFAULT NULL,
budget_item_id INT DEFAULT NULL,
holiday_id INT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
target DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL,
sort_order INT DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_alloc_values (
id INT AUTO_INCREMENT PRIMARY KEY,
month_date VARCHAR(10) NOT NULL,
cat_id INT NOT NULL,
amount_alex DECIMAL(10,2) DEFAULT 0,
amount_laia DECIMAL(10,2) DEFAULT 0,
UNIQUE KEY uq_alloc (month_date, cat_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_savings (
id INT AUTO_INCREMENT PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
month_date VARCHAR(10) NOT NULL,
category VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) DEFAULT 0,
holiday_id INT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_bank_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
snapshot_date DATE NOT NULL,
amount DECIMAL(12,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_salary_config (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
person VARCHAR(50) NOT NULL,
salary DECIMAL(10,2) DEFAULT 0,
mensualite DECIMAL(10,2) DEFAULT 0,
frais_func DECIMAL(10,2) DEFAULT 0,
eco_perso DECIMAL(10,2) DEFAULT 0,
eco_family DECIMAL(10,2) DEFAULT 0,
UNIQUE KEY uq_salary (year, person)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_import_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
keyword VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
note_type VARCHAR(100) NOT NULL,
reference_id VARCHAR(100) NOT NULL,
content TEXT,
UNIQUE KEY uq_note (note_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Voyages / Holidays ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_holidays (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
period_hint VARCHAR(100) DEFAULT NULL,
start_date DATE DEFAULT NULL,
end_date DATE DEFAULT NULL,
status VARCHAR(50) DEFAULT 'draft',
budget_food DECIMAL(10,2) DEFAULT 0,
budget_extra DECIMAL(10,2) DEFAULT 0,
notes TEXT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_holidays_items (
id INT AUTO_INCREMENT PRIMARY KEY,
holiday_id INT NOT NULL,
category VARCHAR(100),
name VARCHAR(255),
amount DECIMAL(10,2) DEFAULT 0,
is_paid TINYINT(1) DEFAULT 0,
location_name VARCHAR(255) DEFAULT NULL,
lat DECIMAL(10,7) DEFAULT NULL,
lng DECIMAL(10,7) DEFAULT NULL,
sort_order INT DEFAULT 0,
notes TEXT DEFAULT NULL,
item_date DATE DEFAULT NULL,
item_time TIME DEFAULT NULL,
step_start_date DATE DEFAULT NULL,
step_end_date DATE DEFAULT NULL,
duration INT DEFAULT NULL,
is_return TINYINT(1) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_holidays_ideas (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
period_hint VARCHAR(100) DEFAULT NULL,
start_date DATE DEFAULT NULL,
end_date DATE DEFAULT NULL,
status VARCHAR(50) DEFAULT 'idea',
budget_food DECIMAL(10,2) DEFAULT 0,
budget_extra DECIMAL(10,2) DEFAULT 0,
notes TEXT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_geocode_cache (
q_hash CHAR(64) PRIMARY KEY,
q VARCHAR(255),
lat DECIMAL(10,7),
lng DECIMAL(10,7),
display_name TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Cadeaux ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_gifts (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
adult_name VARCHAR(100) NOT NULL,
payer_name VARCHAR(100),
child_name VARCHAR(100) NOT NULL,
occasion VARCHAR(50) NOT NULL,
gift_description TEXT NOT NULL,
product_link VARCHAR(500) DEFAULT NULL,
amount DECIMAL(8,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
-- ─── Données initiales ────────────────────────────────────────────────────────
-- Utilisateur admin (mot de passe : changeme → à modifier !)
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name)
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin');
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
-- ─── Garage Manager ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_vehicles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
brand VARCHAR(100) NOT NULL,
model VARCHAR(100) NOT NULL,
year INT DEFAULT NULL,
license_plate VARCHAR(50) DEFAULT NULL,
vin VARCHAR(100) DEFAULT NULL,
fuel_type VARCHAR(50) DEFAULT 'Essence',
color VARCHAR(50) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
purchase_price DECIMAL(10,2) DEFAULT NULL,
current_km INT DEFAULT 0,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_maintenances (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT DEFAULT NULL,
date DATE NOT NULL,
km INT DEFAULT NULL,
cost DECIMAL(10,2) DEFAULT 0,
mechanic VARCHAR(100) DEFAULT NULL,
garage_name VARCHAR(100) DEFAULT NULL,
next_km INT DEFAULT NULL,
next_date DATE DEFAULT NULL,
invoice_photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_parts (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT DEFAULT NULL,
maintenance_id INT DEFAULT NULL,
brand VARCHAR(100) DEFAULT NULL,
reference VARCHAR(100) DEFAULT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) DEFAULT 'Autre',
price DECIMAL(10,2) DEFAULT 0,
quantity INT DEFAULT 1,
unit VARCHAR(50) DEFAULT 'pièce',
supplier VARCHAR(100) DEFAULT NULL,
purchase_date DATE DEFAULT NULL,
photo VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_garage_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maintenance_id INT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
mime VARCHAR(120) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_gd_vehicle (vehicle_id),
KEY idx_gd_maint (maintenance_id),
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_memo_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT DEFAULT '',
tags VARCHAR(1000) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, content, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
filename VARCHAR(255) DEFAULT NULL,
original_name VARCHAR(255) DEFAULT NULL,
url TEXT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Todo ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_todo_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
color VARCHAR(20) DEFAULT '#3b82f6',
icon VARCHAR(10) DEFAULT '📋',
position INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_todos (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT DEFAULT NULL,
title VARCHAR(500) NOT NULL,
notes TEXT DEFAULT NULL,
due_date DATE DEFAULT NULL,
due_time TIME DEFAULT NULL,
notified TINYINT(1) DEFAULT 0,
notified_date DATE DEFAULT NULL,
priority ENUM('none','low','medium','high') DEFAULT 'none',
done TINYINT(1) DEFAULT 0,
done_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Module Liste (multi-listes) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
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;
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;
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 ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
family_id INT NOT NULL,
created_by_user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT NULL,
location VARCHAR(255) DEFAULT NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
is_all_day TINYINT(1) DEFAULT 0,
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
rrule VARCHAR(500) DEFAULT NULL,
status VARCHAR(50) DEFAULT 'confirmed',
external_uid VARCHAR(255) DEFAULT NULL,
sync_state VARCHAR(30) DEFAULT 'pending_push',
deleted_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_external_uid (external_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_event_id INT NOT NULL,
external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── PrintVault ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_pv_models (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
category VARCHAR(100) DEFAULT 'Non classé',
tags VARCHAR(500) DEFAULT '',
file_type VARCHAR(20) NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size INT DEFAULT 0,
dim_x DECIMAL(10,3) DEFAULT 0,
dim_y DECIMAL(10,3) DEFAULT 0,
dim_z DECIMAL(10,3) DEFAULT 0,
volume DECIMAL(10,3) DEFAULT 0,
gcode_time VARCHAR(50) DEFAULT '',
gcode_filament VARCHAR(50) DEFAULT '',
gcode_nozzle VARCHAR(20) DEFAULT '',
gcode_bed VARCHAR(20) DEFAULT '',
thumb VARCHAR(255) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_pv_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(20) DEFAULT '#8b5cf6'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
('Architecture','#8b5cf6'),('Art','#06b6d4');
-- ─── Planka ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_planka_config (
id INT NOT NULL DEFAULT 1,
project_id VARCHAR(32),
admin_token TEXT,
token_expires_at DATETIME,
active_board_id VARCHAR(32),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* HouseHub OS - Source Extractor 🦙🚀
* Génère un fichier Markdown contenant tout le code source du projet.
* Idéal pour alimenter une GEM ou un LLM.
*/
// 1. 🚨 SÉCURITÉ : Ne jamais laisser ce fichier accessible publiquement sans protection !
// Appelle le script via : https://househub.nas.../extract_source.php?key=TON_MOT_DE_PASSE
$secretKey = 'mR83s7MmXbP$jer$9C4HnGkry6xhGL6';
if (!isset($_GET['key']) || $_GET['key'] !== $secretKey) {
http_response_code(403);
die("⛔ Accès refusé. Veuillez fournir la clé de sécurité.");
}
// Forcer l'affichage en texte brut dans le navigateur pour un copier-coller facile
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
// 2. CONFIGURATION DU FILTRAGE
// Dossiers à exclure (ne mets pas de slash au début)
$excludeDirs = [
'.git',
'.gitea',
'.devtools',
'vendor',
'node_modules',
'assets/img',
'modules/holidays/assets/img',
'modules/family-calendar/assets/img',
'modules/gift-list/assets/img'
];
// Fichiers spécifiques à exclure (Sécurité & Bruit)
$excludeFiles = [
'.env',
'.env.example',
'extract_source.php', // On s'exclut soi-même
'pachafamily_source.md',
'docker-compose.override.yml'
];
// Extensions autorisées (on ignore les images, pdf, zip, etc.)
$allowedExtensions = ['php', 'js', 'css', 'sql', 'json', 'yml', 'yaml', 'md', 'sh'];
// 3. INITIALISATION DU PARCOURS
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$report = "# 🦙 Source Code HouseHub OS\n\n";
$report .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$report .= "---\n\n";
// 4. BOUCLE D'EXTRACTION
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
$relativePath = str_replace('\\', '/', $relativePath); // Uniformisation Windows/Linux
// Extraction de l'extension
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// A. Filtrage par dossier
$skip = false;
foreach ($excludeDirs as $dir) {
// Si le chemin commence par ce dossier, ou contient /ce_dossier/
if (strpos($relativePath, $dir . '/') === 0 || strpos($relativePath, '/' . $dir . '/') !== false) {
$skip = true;
break;
}
}
if ($skip) continue;
// B. Filtrage par nom de fichier
if (in_array(basename($filePath), $excludeFiles)) continue;
// C. Filtrage par extension
if (!in_array($ext, $allowedExtensions)) continue;
// 5. LECTURE ET FORMATAGE
$content = file_get_contents($filePath);
if ($content === false) {
$content = "// Erreur lors de la lecture de ce fichier.";
}
// Ajustement du tag de langage pour le Markdown
$lang = $ext;
if ($ext === 'js') $lang = 'javascript';
if ($ext === 'yml') $lang = 'yaml';
$report .= "### 📄 Fichier : `$relativePath`\n";
$report .= "```$lang\n";
$report .= trim($content) . "\n";
$report .= "```\n\n---\n\n";
}
// 6. SORTIE
echo $report;
+236 -110
View File
@@ -1,135 +1,261 @@
<?php
// Active l'affichage des erreurs pour le développement
// modules/family-calendar/family-calendar.php
ini_set('display_errors', 1);
error_reporting(E_ALL);
// On se connecte à la base de données
require __DIR__ . '/includes/auth.php';
require_login();
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
// --- On récupère TOUS les événements sauvegardés en base ---
$stmt_events = $pdo->query("SELECT * FROM pf_events");
$dbEvents = $stmt_events->fetchAll();
// --- Configuration de la page ---
$pageTitle = "PachaFamily - Family Calendar";
$pageTitle = tr('fc_page_title');
$activePage = "family-calendar";
$mainClass = "pf-family-calendar";
$pageCss = "/modules/family-calendar/family-calendar.css";
require __DIR__ . '/header.php';
?>
<!-- ===================================================================== -->
<!-- INJECTION DES DONNÉES DU SERVEUR VERS JAVASCRIPT -->
<!-- Cette variable `serverData` sera lue par le script JS au démarrage. -->
<!-- ===================================================================== -->
<script>
const serverData = <?php echo json_encode($dbEvents, JSON_NUMERIC_CHECK); ?>;
</script>
<h1>Family Calendar</h1>
<!-- ===================================================================== -->
<!-- PANNEAU DE CONTRÔLE : Légende, Récapitulatif et Vacances -->
<!-- ===================================================================== -->
<section class="pf-section pf-section--panel">
<div class="pf-flex pf-flex--wrap pf-gap-lg">
<!-- LÉGENDE -->
<div class="pf-card pf-card--small">
<h2 class="pf-card-title">Légende</h2>
<div class="pf-card-body">
<div class="pf-legend-item">
<div class="pf-legend-color fc-day--school-holiday"></div>
<span>Vacances scolaires</span>
<div class="pf-container" style="max-width:100%; padding:0;">
<div class="fc-header-row">
<h1><?= tr('fc_main_header') ?></h1>
<div style="display: flex; gap: 10px;">
<button id="btnOpenSnapshotModal" class="pf-btn-icon-text">
<span class="icon">⚖️</span>
<span class="text"><?= tr('fc_btn_correct_balances') ?></span>
</button>
<button id="btnOpenHolidays" class="pf-btn-icon-text">
<span class="icon">🏖️</span>
<span class="text"><?= tr('fc_btn_school_holidays') ?></span>
</button>
</div>
<div class="pf-legend-item">
<div class="pf-legend-color fc-day--public-holiday"></div>
<span>Jour férié</span>
</div>
<div class="pf-legend-item">
<div class="pf-legend-color fc-day--off-carole"></div>
<span>Off Carole</span>
</div>
<div class="pf-legend-item">
<div class="pf-legend-color fc-day--extra-off-carole"></div>
<span>Extra Off Carole</span>
</div>
</div>
</div>
<!-- RÉCAPITULATIF ANNUEL -->
<div class="pf-card pf-card--small">
<h2 class="pf-card-title">Récapitulatif annuel</h2>
<div class="pf-card-body" id="globalSummary">
<!-- Rempli par le JavaScript -->
</div>
<div id="modalHolidays" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">
<?= tr('fc_modal_holidays_title') ?>
<?php if (defined('ZONE_SCOLAIRE') && ZONE_SCOLAIRE !== 'Autre') echo '(Zone ' . htmlspecialchars(ZONE_SCOLAIRE) . ')'; ?>
</h3>
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('active')">&times;</button>
</div>
<div class="pf-modal-body" style="padding:0;">
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table id="schoolHolidaysTable" class="fc-holidays-table" style="min-width: 400px;">
<thead>
<tr>
<th><?= tr('fc_col_period') ?></th>
<th><?= tr('fc_col_from') ?></th>
<th><?= tr('fc_col_to') ?></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- VACANCES SCOLAIRES -->
<div class="pf-card pf-card--small pf-card--wide">
<h2 class="pf-card-title">Vacances scolaires - Zone C (2025-2026)</h2>
<div class="pf-card-body">
<div class="pf-table-wrapper">
<table id="schoolHolidaysTable" class="fc-holidays-table">
<thead>
<tr>
<th>Période</th>
<th>Du</th>
<th>Au</th>
<th>Zones</th>
</tr>
</thead>
<tbody>
<!-- Rempli par JS -->
</tbody>
</table>
<div id="modalSnapshot" class="pf-modal">
<div class="pf-modal-content">
<div class="pf-modal-header">
<h3 class="pf-modal-title">📸 <?= tr('fc_modal_snap_title') ?></h3>
<button type="button" id="btnCloseSnapshot" class="pf-modal-close" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');">&times;</button>
</div>
<div class="pf-modal-body">
<form id="formSnapshot">
<div class="pf-form-row">
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_person') ?></label>
<select id="snapPerson" class="pf-input" required>
<option value="2">Alex</option>
<option value="3">Laia</option>
</select>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_leave_type') ?></label>
<select id="snapType" class="pf-input" required>
<option value="CP">CP</option>
<option value="JRA">JRA</option>
<option value="JA">JA</option>
</select>
</div>
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_apply_date') ?></label>
<input type="date" id="snapDate" class="pf-input" required title="<?= tr('fc_snap_date_hint') ?>">
</div>
<div class="pf-form-group">
<label class="pf-label"><?= tr('fc_label_remaining_balance') ?></label>
<div style="display:flex; align-items:center;">
<input type="number" step="0.5" id="snapBalance" class="pf-input" placeholder="<?= tr('fc_placeholder_balance') ?>" style="border-radius: 8px 0 0 8px; border-right: none; font-weight: bold; color: var(--pf-primary);" required>
<span style="background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); padding: 0 12px; height: 42px; display: flex; align-items: center; border-radius: 0 8px 8px 0; color: var(--pf-text-muted); font-size: 0.9rem; font-weight: 600;">
<?= tr('fc_unit_days') ?>
</span>
</div>
</div>
</form>
</div>
<div class="pf-modal-footer">
<button type="button" class="pf-btn pf-btn-secondary" onclick="document.getElementById('modalSnapshot').classList.remove('open'); document.body.classList.remove('no-scroll');"><?= tr('btn_cancel') ?></button>
<button type="submit" form="formSnapshot" class="pf-btn pf-btn-primary"><?= tr('fc_btn_save_snap') ?></button>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="pf-section">
<div class="fc-month-calendar-wrapper">
<div class="fc-month-header">
<div class="fc-month-nav-row">
<button id="fc-prev-month" class="fc-nav-button"></button>
<div id="fc-smart-date-selector" style="display:flex; align-items:center; justify-content:center; flex-grow:1; gap:6px;">
<select id="fc-select-month" class="fc-smart-select"></select>
<select id="fc-select-year" class="fc-smart-select"></select>
<span id="fc-multi-month-suffix" style="display:none; font-size:1.3rem; font-weight:800; color:#0f172a; margin-left:4px;"></span>
</div>
<button id="fc-next-month" class="fc-nav-button"></button>
</div>
<div class="fc-view-controls">
<button id="fc-today-btn" class="fc-today-button" title="<?= tr('fc_today_title') ?>"><?= tr('fc_today_short') ?></button> <div class="fc-view-divider"></div>
<button class="fc-view-button fc-view-button--active" data-view="1month"><?= tr('fc_view_1m') ?></button>
<button class="fc-view-button" data-view="2months"><?= tr('fc_view_2m') ?></button>
<button class="fc-view-button" data-view="3months"><?= tr('fc_view_3m') ?></button>
</div>
</div>
<div class="fc-calendar-container">
<div id="fc-month-calendar" class="fc-month-calendar"></div>
<div id="fc-month-selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</div>
<!-- ===================================================================== -->
<!-- PLANNING PRINCIPAL -->
<!-- ===================================================================== -->
<section class="pf-section">
<h2>Planning par semaine</h2>
<div class="pf-table-wrapper">
<table id="planningTable" class="pf-table pf-table--compact">
<thead>
<tr>
<th>Mois</th>
<th>Semaine</th>
<th>Lundi</th>
<th>Mardi</th>
<th>Mercredi</th>
<th>Jeudi</th>
<th>Vendredi</th>
<th># Off Carole</th>
<th># Extra off Carole</th>
<th>#Centre</th>
<th>#Avis</th>
</tr>
</thead>
<tbody id="planningBody">
<!-- Le contenu de ce tableau est entièrement généré par family-calendar.js -->
</tbody>
</table>
<!-- Le menu contextuel est caché par défaut et son contenu est généré par JS -->
<div id="selectionMenu" class="fc-selection-menu"></div>
</div>
</section>
<div id="fc-month-balances" class="fc-month-balances"></div>
</div>
</section>
<section class="pf-section">
<div class="fc-week-header">
<h2><?= tr('fc_weekly_planning') ?></h2>
<div class="fc-week-nav-controls">
<button id="fc-prev-school-year" class="fc-nav-button"></button>
<span id="fc-current-school-year-label"></span>
<button id="fc-next-school-year" class="fc-nav-button"></button>
</div>
</div>
<!-- ===================================================================== -->
<!-- CHARGEMENT DU SCRIPT JAVASCRIPT PRINCIPAL -->
<!-- ===================================================================== -->
<script src="/assets/js/family-calendar.js"></script>
<div id="planningTable-wrapper">
<table id="planningTable">
<thead>
<tr>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_off_carole') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_extra_off') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_centre') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_avis') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_pep_sick') ?></span></th>
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_presence') ?></span></th>
<th colspan="6" class="col-alex header-group">ALEX</th>
<th colspan="6" class="col-laia header-group">LAIA</th>
</tr>
<tr>
<th colspan="2" class="col-alex-sub">CP</th><th colspan="2" class="col-alex-sub">JRA</th><th colspan="2" class="col-alex-sub">JA</th>
<th colspan="2" class="col-laia-sub">CP</th><th colspan="2" class="col-laia-sub">JRA</th><th colspan="2" class="col-laia-sub">JA</th>
</tr>
<tr>
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
</tr>
</thead>
<tbody id="planningBody"></tbody>
</table>
<div id="selectionMenu" class="fc-selection-menu" style="display:none;"></div>
</div>
</section>
<section class="pf-section pf-section--bottom-panels">
<div class="fc-bottom-grid">
<?php
// Inclusion du pied de page
require __DIR__ . '/footer.php';
?>
<div class="pf-card">
<div class="pf-card-title fc-summary-header">
<span><?= tr('fc_summary_title') ?></span>
<div class="fc-summary-controls">
<select id="summType" class="fc-summ-select">
<option value="year"><?= tr('fc_summ_year') ?></option>
<option value="month"><?= tr('fc_summ_month') ?></option>
</select>
<select id="summValue" class="fc-summ-select"></select>
</div>
</div>
<div class="pf-card-body">
<div id="globalSummary" class="fc-summary-vertical"></div>
</div>
</div>
<div class="pf-card">
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
<div class="pf-card-body">
<div class="pf-legend-grid">
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-school-holiday"></div><span><?= tr('leg_school_holidays') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-off-carole"></div><span><?= tr('leg_off_carole') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-extra-off-carole"></div><span><?= tr('leg_extra_off') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-centre"></div><span><?= tr('leg_centre') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-avis"></div><span><?= tr('leg_avis') ?></span></div>
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-pep-sick"></div><span><?= tr('leg_pep_sick') ?></span></div>
</div>
</div>
</div>
</div>
</section>
</div>
<script>
window.I18N = {
...(window.I18N || {}),
'fc_menu_carole': "<?= tr('fc_menu_carole') ?>",
'btn_off': "<?= tr('btn_off') ?>",
'btn_extra': "<?= tr('btn_extra') ?>",
'leg_centre': "<?= tr('leg_centre') ?>",
'leg_avis': "<?= tr('leg_avis') ?>",
'leg_pep_sick': "<?= tr('leg_pep_sick') ?>",
'leg_off_carole': "<?= tr('leg_off_carole') ?>",
'leg_extra_off': "<?= tr('leg_extra_off') ?>",
'leg_presence': "<?= tr('leg_presence') ?>",
'fc_menu_kids_leaves': "<?= tr('fc_menu_kids_leaves') ?>",
'fc_clear': "<?= tr('fc_clear') ?>",
'fc_unit_days': "<?= tr('fc_unit_days') ?>",
'vac_toussaint': "<?= tr('vac_toussaint') ?>",
'vac_noel': "<?= tr('vac_noel') ?>",
'vac_hiver': "<?= tr('vac_hiver') ?>",
'vac_printemps': "<?= tr('vac_printemps') ?>",
'vac_ascension': "<?= tr('vac_ascension') ?>",
'vac_ete': "<?= tr('vac_ete') ?>",
'leg_school_holidays': "<?= tr('leg_school_holidays') ?>",
'fc_school_year': "<?= tr('fc_school_year') ?>",
'fc_modal_holidays_title': "<?= tr('fc_modal_holidays_title') ?>",
'fc_alert_burn_days': "<?= tr('fc_alert_burn_days') ?>",
'fc_alert_burn_jra': "<?= tr('fc_alert_burn_jra') ?>",
'ANNIV': "<?= tr('ANNIV') ?>"
};
</script>
<script src="/modules/family-calendar/family-calendar.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+3 -1
View File
@@ -2,8 +2,10 @@
<footer class="pf-footer">
<div class="pf-container">
<small>&copy; <?php echo date('Y'); ?> PachaFamily</small>
<small>&copy; <?php echo date('Y'); ?> HouseHub</small>
</div>
</footer>
</div> <!-- FERMETURE pf-page -->
</body>
</html>
+363
View File
@@ -0,0 +1,363 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('garage_page_title');
$activePage = "garage";
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/garage/assets/garage.css">
<div id="toasts" class="toast-container"></div>
<!-- Sous-navigation -->
<nav class="garage-subnav">
<button class="nav-link active" data-page="dashboard">📊 Dashboard</button>
<button class="nav-link" data-page="vehicles">🚗 Véhicules</button>
<button class="nav-link" data-page="maintenances-all">🔧 Entretiens</button>
<button class="nav-link" data-page="parts">🔩 Pièces</button>
</nav>
<!-- ── DASHBOARD ─────────────────────────────────────────────────────────── -->
<div id="page-dashboard" class="page active">
<div class="container">
<div class="stats-grid">
<div class="stat-pill blue"><div class="label">Véhicules</div><div class="value" id="stat-vehicles">--</div></div>
<div class="stat-pill green"><div class="label">Entretiens</div><div class="value" id="stat-maintenances">--</div></div>
<div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div>
<div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
</div>
<div class="garage-dash-layout">
<div class="garage-dash-main">
<div class="card-header garage-card-header" style="margin-bottom:.75rem">
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
</div>
<div id="dashboard-vehicles" class="vehicles-grid"></div>
</div>
<div class="card garage-dash-aside">
<div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div>
<div id="reminders-list"></div>
</div>
</div>
</div>
</div>
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
<div id="page-vehicles" class="page">
<div class="container">
<div class="card-header garage-card-header" style="margin-bottom:1rem">
<h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1>
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
</div>
<div id="vehicles-grid" class="vehicles-grid"></div>
</div>
</div>
<!-- ── DÉTAIL VÉHICULE ─────────────────────────────────────────────────────── -->
<div id="page-vehicle" class="page">
<div class="container">
<div style="margin-bottom:1rem">
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
</div>
<div id="vehicle-header"></div>
<div class="tabs garage-tabs">
<button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button>
<button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button>
<button class="tab-btn" data-tab="documents" onclick="switchTab('documents')">📎 Documents <span id="vehicle-docs-tab-count" style="color:var(--muted);font-size:.78rem"></span></button>
</div>
<div id="tab-maintenances" class="tab-pane active">
<div class="garage-tab-actions">
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
</div>
<div id="maintenance-list"></div>
</div>
<div id="tab-parts" class="tab-pane">
<div class="garage-tab-actions">
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
</div>
<div id="parts-list-vehicle"></div>
</div>
<div id="tab-documents" class="tab-pane">
<div class="garage-doc-upload-bar">
<div class="form-group" style="flex:1;min-width:0;margin:0">
<label class="form-label" for="vehicle-doc-label">Libellé (optionnel)</label>
<input type="text" class="form-control" id="vehicle-doc-label" placeholder="Carte grise, assurance…">
</div>
<div class="form-group" style="margin:0;flex:1;min-width:140px">
<label class="form-label" for="vehicle-doc-file">Fichier</label>
<input type="file" class="form-control" id="vehicle-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
</div>
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadVehicleDocument()">Envoyer</button>
</div>
<p class="garage-doc-hint">PDF, images, Office… · max 12 Mo</p>
<div id="vehicle-documents-list"></div>
</div>
</div>
</div>
<!-- ── DÉTAIL ENTRETIEN ─────────────────────────────────────────────────────── -->
<div id="page-maintenance" class="page">
<div class="container">
<div style="margin-bottom:1rem">
<button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button>
</div>
<div id="maintenance-detail-header"></div>
<div class="card garage-docs-card" style="margin-top:1rem">
<div class="card-header garage-card-header">
<span class="card-title">📎 Documents & factures (cette révision)</span>
</div>
<div style="padding:0 1.25rem 1rem">
<div class="garage-doc-upload-bar">
<div class="form-group" style="flex:1;min-width:0;margin:0">
<label class="form-label" for="maint-doc-label">Libellé (ex. facture garage)</label>
<input type="text" class="form-control" id="maint-doc-label" placeholder="Facture, devis…">
</div>
<div class="form-group" style="margin:0;flex:1;min-width:140px">
<label class="form-label" for="maint-doc-file">Fichier</label>
<input type="file" class="form-control" id="maint-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
</div>
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadMaintenanceDocument()">Envoyer</button>
</div>
<p class="garage-doc-hint">PDF, images… · max 12 Mo</p>
<div id="maintenance-documents-list"></div>
</div>
</div>
<div class="card" style="padding:0;margin-top:1.5rem">
<div class="card-header">
<span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span>
<button class="btn btn-primary btn-sm" onclick="toggleInlinePartForm()">+ Ajouter une pièce</button>
</div>
<!-- Formulaire inline ajout pièce -->
<div id="inline-part-form" style="display:none;background:#f8fafc;border-bottom:1px solid #e2e8f0;padding:1.25rem 1.5rem">
<p style="font-size:.8rem;font-weight:600;color:#64748b;margin-bottom:.75rem;text-transform:uppercase;letter-spacing:.05em">🔄 Réutiliser une pièce connue</p>
<div id="known-parts-list"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="ipart-name" placeholder="Filtre à huile"></div>
<div class="form-group"><label class="form-label">Référence</label><input class="form-control" id="ipart-reference" placeholder="Ex: 06A115561B" style="font-family:monospace"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
<div class="form-group"><label class="form-label">Marque</label><input class="form-control" id="ipart-brand" placeholder="Bosch, NGK..."></div>
<div class="form-group">
<label class="form-label">Prix unitaire (€)</label>
<input class="form-control" id="ipart-price" type="number" step="0.01" value="0" oninput="updateIPriceTTC()">
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:.8rem;color:#64748b;cursor:pointer">
<input type="checkbox" id="ipart-prix-ht" onchange="updateIPriceTTC()" style="width:14px;height:14px"> Prix HT (+20% TVA)
</label>
<p id="ipart-ttc-preview" style="display:none;font-size:.8rem;color:#2563eb;margin-top:.2rem;font-family:monospace"></p>
</div>
<div class="form-group"><label class="form-label">Quantité</label><input class="form-control" id="ipart-quantity" type="number" value="1" min="1"></div>
</div>
<div class="form-group"><label class="form-label">Photo (optionnel)</label><input class="form-control" id="ipart-photo" type="file" accept="image/*"></div>
<div style="display:flex;gap:.5rem;margin-top:.5rem">
<button class="btn btn-primary btn-sm" onclick="saveInlinePart()">+ Ajouter</button>
<button class="btn btn-secondary btn-sm" onclick="toggleInlinePartForm()">Annuler</button>
</div>
</div>
<div id="maintenance-parts-list"></div>
</div>
</div>
</div>
<!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── -->
<div id="page-maintenances-all" class="page">
<div class="container">
<div class="card-header garage-card-header" style="margin-bottom:1rem">
<div>
<h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1>
<p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p>
</div>
</div>
<div id="all-maintenances-list"></div>
</div>
</div>
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
<div id="page-parts" class="page">
<div class="container">
<div class="card-header garage-card-header" style="margin-bottom:1rem">
<div>
<h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1>
<p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p>
</div>
<button class="btn btn-primary" onclick="openAddPart()">+ Ajouter une pièce</button>
</div>
<div id="all-parts-list"></div>
</div>
</div>
<!-- ══ MODALS ════════════════════════════════════════════════════════════════ -->
<!-- MODAL: VEHICLE -->
<div id="modal-vehicle" class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-vehicle-title">Ajouter un véhicule</div>
<button class="modal-close" onclick="closeModal('modal-vehicle')">✕</button>
</div>
<div class="modal-body">
<form id="form-vehicle" onsubmit="return false">
<input type="hidden" id="form-vehicle-id" name="id">
<div class="form-row">
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="vehicle-name" name="name" required placeholder="Ma Clio"></div>
<div class="form-group"><label class="form-label">Marque *</label><input class="form-control" id="vehicle-brand" name="brand" required placeholder="Renault"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Modèle *</label><input class="form-control" id="vehicle-model" name="model" required placeholder="Clio 4"></div>
<div class="form-group"><label class="form-label">Année</label><input class="form-control" id="vehicle-year" name="year" type="number" placeholder="2019"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Immatriculation</label><input class="form-control" id="vehicle-license-plate" name="license_plate" placeholder="AB-123-CD"></div>
<div class="form-group"><label class="form-label">Carburant</label>
<select class="form-control" id="vehicle-fuel-type" name="fuel_type">
<option>Essence</option><option>Diesel</option><option>Hybride</option><option>Electrique</option><option>GPL</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="vehicle-current-km" name="current_km" type="number" placeholder="85000"></div>
<div class="form-group"><label class="form-label">Couleur</label><input class="form-control" id="vehicle-color" name="color" placeholder="Blanc nacré"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Date d'achat</label><input class="form-control" id="vehicle-purchase-date" name="purchase_date" type="date"></div>
<div class="form-group"><label class="form-label">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></div>
</div>
<div class="form-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></div>
<div class="form-group">
<label class="form-label">Photo</label>
<input class="form-control" id="vehicle-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('vehicle-photo','vehicle-photo-preview')">
<img id="vehicle-photo-preview" style="display:none;max-height:120px;margin-top:.5rem;border-radius:6px" alt="">
</div>
<div class="form-group"><label class="form-label">Notes</label><textarea class="form-control" id="vehicle-notes" name="notes" rows="2"></textarea></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-vehicle')">Annuler</button>
<button class="btn btn-primary" onclick="saveVehicle()">Enregistrer</button>
</div>
</div>
</div>
<!-- MODAL: MAINTENANCE -->
<div id="modal-maintenance" class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-maintenance-title">Ajouter un entretien</div>
<button class="modal-close" onclick="closeModal('modal-maintenance')">✕</button>
</div>
<div class="modal-body">
<form id="form-maintenance" onsubmit="return false">
<input type="hidden" id="form-maintenance-id" name="id">
<input type="hidden" id="maintenance-vehicle-id" name="vehicle_id">
<div class="form-row">
<div class="form-group"><label class="form-label">Type *</label>
<select class="form-control" id="maintenance-type" name="type">
<option>Vidange</option><option>Révision</option><option>Freins</option><option>Pneus</option>
<option>Distribution</option><option>Filtres</option><option>Batterie</option><option>Climatisation</option>
<option>Carrosserie</option><option>Diagnostic</option><option>Contrôle technique</option><option>Autre</option>
</select>
</div>
<div class="form-group"><label class="form-label">Date *</label><input class="form-control" id="maintenance-date" name="date" type="date" required></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Kilométrage</label><input class="form-control" id="maintenance-km" name="km" type="number" placeholder="85000"></div>
<div class="form-group"><label class="form-label">Coût main d'œuvre (€)</label><input class="form-control" id="maintenance-cost" name="cost" type="number" step="0.01" placeholder="0"></div>
</div>
<div class="form-group"><label class="form-label">Description</label><textarea class="form-control" id="maintenance-description" name="description" rows="2"></textarea></div>
<div class="form-row">
<div class="form-group"><label class="form-label">Mécanicien</label><input class="form-control" id="maintenance-mechanic" name="mechanic"></div>
<div class="form-group"><label class="form-label">Garage / Atelier</label><input class="form-control" id="maintenance-garage" name="garage_name"></div>
</div>
<div style="background:rgba(37,99,235,.05);border:1px solid rgba(37,99,235,.1);border-radius:8px;padding:1rem;margin-top:.5rem">
<div style="font-size:.82rem;color:var(--muted);margin-bottom:.75rem;font-weight:600">🔔 Prochain entretien</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Date prévue</label><input class="form-control" id="maintenance-next-date" name="next_date" type="date"></div>
<div class="form-group"><label class="form-label">Kilométrage prévu</label><input class="form-control" id="maintenance-next-km" name="next_km" type="number" placeholder="95000"></div>
</div>
</div>
<div class="form-group" style="margin-top:1rem"><label class="form-label">Notes</label><textarea class="form-control" id="maintenance-notes" name="notes" rows="2"></textarea></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-maintenance')">Annuler</button>
<button class="btn btn-primary" onclick="saveMaintenance()">Enregistrer</button>
</div>
</div>
</div>
<!-- MODAL: PART -->
<div id="modal-part" class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-part-title">Ajouter une pièce</div>
<button class="modal-close" onclick="closeModal('modal-part')">✕</button>
</div>
<div class="modal-body">
<form id="form-part" onsubmit="return false">
<input type="hidden" id="form-part-id" name="id">
<input type="hidden" id="part-vehicle-id" name="vehicle_id">
<input type="hidden" id="part-maintenance-id" name="maintenance_id">
<div class="form-row">
<div class="form-group"><label class="form-label">Nom *</label><input class="form-control" id="part-name" name="name" required placeholder="Filtre à huile"></div>
<div class="form-group"><label class="form-label">Catégorie</label>
<select class="form-control" id="part-category" name="category">
<option>Moteur</option><option>Freinage</option><option>Suspension</option><option>Transmission</option>
<option>Carrosserie</option><option>Electricite</option><option>Eclairage</option><option>Filtration</option>
<option>Refroidissement</option><option>Echappement</option><option>Pneumatiques</option><option>Autre</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Marque</label><input class="form-control" id="part-brand" name="brand" placeholder="Bosch, NGK..."></div>
<div class="form-group"><label class="form-label">Référence</label><input class="form-control" id="part-reference" name="reference"></div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Prix unitaire (€)</label>
<input class="form-control" id="part-price" name="price" type="number" step="0.01" placeholder="12.50" oninput="updatePartPriceTTC()">
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:.8rem;color:#64748b;cursor:pointer">
<input type="checkbox" id="part-prix-ht" onchange="updatePartPriceTTC()" style="width:14px;height:14px"> Prix HT (+20% TVA)
</label>
<p id="part-ttc-preview" style="display:none;font-size:.8rem;color:#2563eb;margin-top:.2rem;font-family:monospace"></p>
</div>
<div class="form-group"><label class="form-label">Quantité</label><input class="form-control" id="part-quantity" name="quantity" type="number" value="1" min="1"></div>
</div>
<div class="form-group">
<label class="form-label">Photo</label>
<input class="form-control" id="part-photo" name="photo" type="file" accept="image/*" onchange="previewPhoto('part-photo','part-photo-preview')">
<img id="part-photo-preview" style="display:none;max-height:100px;margin-top:.5rem;border-radius:6px" alt="">
</div>
<div class="form-group"><label class="form-label">Notes</label><textarea class="form-control" id="part-notes" name="notes" rows="2"></textarea></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-part')">Annuler</button>
<button class="btn btn-primary" onclick="savePart()">Enregistrer</button>
</div>
</div>
</div>
<!-- MODAL: UPLOAD PHOTO VÉHICULE -->
<div id="modal-upload-photo" class="modal-backdrop">
<div class="modal" style="max-width:400px">
<div class="modal-header">
<div class="modal-title">📷 Changer la photo</div>
<button class="modal-close" onclick="closeModal('modal-upload-photo')">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="upload-vehicle-id">
<div class="form-group"><label class="form-label">Nouvelle photo</label><input class="form-control" id="upload-photo-file" type="file" accept="image/*"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-upload-photo')">Annuler</button>
<button class="btn btn-primary" onclick="doUploadPhoto()">Mettre à jour</button>
</div>
</div>
</div>
<script src="/modules/garage/assets/garage.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* HouseHub - CSS Auditor 🦙
* Génère un rapport complet de tous les styles (fichiers, balises, inline)
*/
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
$report = "=== 🦙 RAPPORT CSS COMPLET HOUSEHUB ===\n";
$report .= "Généré le : " . date('Y-m-d H:i:s') . "\n\n";
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root, '', $filePath);
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
// 1. EXTRACTION DES FICHIERS .css
if ($ext === 'css') {
$report .= "/* 📄 FICHIER SOURCE : $relativePath */\n";
$report .= file_get_contents($filePath) . "\n";
$report .= str_repeat("-", 40) . "\n\n";
}
// 2. EXTRACTION DES BALISES <style> ET ATTRIBUTS style="" DANS LES .php
if ($ext === 'php') {
$content = file_get_contents($filePath);
$foundInFile = false;
// Extraction des blocs <style>
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $content, $matches)) {
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
foreach ($matches[1] as $idx => $styleBlock) {
$report .= "/* Bloc <style> #$idx */\n";
$report .= trim($styleBlock) . "\n";
}
}
// Extraction des styles inline style="..."
// On capture le tag pour donner du contexte au style inline
if (preg_match_all('/<([a-z0-9]+)[^>]*?\sstyle=["\']([^"]*?)["\'][^>]*>/i', $content, $matches)) {
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
foreach ($matches[0] as $idx => $fullTag) {
// On nettoie un peu pour ne garder que l'essentiel du tag et son style
$report .= "/* Style Inline #$idx */\n";
$report .= trim($fullTag) . "\n";
}
}
if ($foundInFile) {
$report .= str_repeat("-", 40) . "\n\n";
}
}
}
echo $report;
+72
View File
@@ -0,0 +1,72 @@
<?php
// generate_prompt.php - Script pour extraire le code de HouseHub
// À lancer via le navigateur (http://localhost:8082/generate_prompt.php) ou en ligne de commande.
$rootDir = __DIR__;
$outputFile = $rootDir . '/househub_source.md';
// Dossiers à ignorer (pour ne pas polluer le cerveau de l'IA avec des choses inutiles)
$ignoredDirs = ['.git', '.devtools', '.gitea', 'assets'];
// Extensions autorisées (on ne veut que du code, pas d'images)
$allowedExtensions = ['php', 'css', 'js', 'html', 'sh'];
$output = "# 🦙 Source Code HouseHub\n\n";
$output .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$fileCount = 0;
foreach ($iterator as $file) {
if ($file->isFile()) {
// Chemin relatif propre
$relativePath = str_replace($rootDir . DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('\\', '/', $relativePath); // Uniformiser pour Windows
// Vérification des dossiers ignorés
$skip = false;
foreach ($ignoredDirs as $ignored) {
if (strpos($relativePath, $ignored) === 0) {
$skip = true;
break;
}
}
if ($skip) continue;
// Vérification de l'extension
$ext = strtolower($file->getExtension());
if (!in_array($ext, $allowedExtensions)) continue;
// On ignore ce script lui-même
if ($file->getFilename() === 'generate_prompt.php') continue;
// Lecture du contenu
$content = file_get_contents($file->getPathname());
// Détermination du langage pour le bloc Markdown
$lang = $ext === 'php' ? 'php' : ($ext === 'js' ? 'javascript' : $ext);
// Formatage pour l'IA
$output .= "### 📄 Fichier : `" . $relativePath . "`\n";
$output .= "```" . $lang . "\n";
$output .= $content . "\n";
$output .= "```\n\n";
$output .= "---\n\n";
$fileCount++;
}
}
file_put_contents($outputFile, $output);
echo "<div style='font-family:sans-serif; padding:20px; background:#f0fdf4; color:#16a34a; border-radius:8px; border:1px solid #bbf7d0;'>";
echo "<h2>✅ Succès !</h2>";
echo "<p>Le fichier <strong>househub_source.md</strong> a été généré à la racine de ton projet.</p>";
echo "<p><strong>$fileCount fichiers</strong> de code ont été fusionnés.</p>";
echo "<p><em>Tu peux maintenant donner ce fichier à ta Gem HouseHub !</em></p>";
echo "</div>";
?>
+534
View File
@@ -0,0 +1,534 @@
<?php
// modules/gift-list/gift-list.php
require __DIR__ . '/includes/auth.php';
require_login('/login.php');
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); }
// --- 1. CONFIGURATION ---
$year = (int)date('Y');
$pageTitle = tr('gift_page_title');
$activePage = "gift-list";
$mainClass = "pf-gift-list";
$pageCss = "/modules/gift-list/gift-list.css";
$children = ['Pol', 'Pep', 'Elna', 'Bru', 'Guim'];
$baseAdults = ['Laia', 'Laura', 'Avi Iaia'];
$extraAdults = ['Pauline', 'Papy JC', 'Mamy Caro'];
$adultsByChildForAnniv = [
'Pol' => array_merge($baseAdults, $extraAdults),
'Pep' => array_merge($baseAdults, $extraAdults),
'Elna' => $baseAdults,
'Bru' => $baseAdults,
'Guim' => $baseAdults,
];
$VIEWS = [
'nadal' => ['TIO', 'NOEL', 'ROIS'],
'anniversary' => ['ANNIV', 'SANT'],
];
$currentView = strtolower($_GET['view'] ?? ($_SESSION['gift_view'] ?? 'nadal'));
if (!isset($VIEWS[$currentView])) $currentView = 'nadal';
$_SESSION['gift_view'] = $currentView;
$allowedOccasions = $VIEWS[$currentView];
$allOccasionLabels = [
'TIO' => tr('gift_occ_tio'), 'NOEL' => tr('gift_occ_noel'), 'ROIS' => tr('gift_occ_rois'),
'ANNIV' => tr('gift_occ_anniv'), 'SANT' => tr('gift_occ_sant')
];
$occasionIcons = [
'TIO' => '/modules/gift-list/assets/img/tio.png', 'NOEL' => '/modules/gift-list/assets/img/santa.png',
'ROIS' => '/modules/gift-list/assets/img/reis.png', 'ANNIV' => '/modules/gift-list/assets/img/corona.png',
'SANT' => '/modules/gift-list/assets/img/sant.png'
];
// --- 2. DONNÉES ---
$inMarks = implode(',', array_fill(0, count($allowedOccasions), '?'));
// Tri: Par Fête, puis Enfant, puis Adulte
$sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$year], $allowedOccasions));
$gifts = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = [];
$adultsInView = [];
foreach ($gifts as $g) {
$data[$g['occasion']][$g['child_name']]['gifts'][] = $g;
$data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] = ($data[$g['occasion']][$g['child_name']]['totals'][$g['adult_name']] ?? 0) + $g['amount'];
$adultsInView[$g['adult_name']] = true;
}
$allAdultsList = array_keys($adultsInView); sort($allAdultsList);
// --- 3. TRICOUNT ---
$people = array_values(array_unique(array_merge($baseAdults, array_column($gifts, 'adult_name'), array_column($gifts, 'payer_name'))));
$people = array_filter($people);
$matrix = [];
foreach ($people as $p1) { foreach ($people as $p2) $matrix[$p1][$p2] = 0.0; }
foreach ($gifts as $g) {
$adult = $g['adult_name'];
$payer = $g['payer_name'] ?? $g['adult_name'];
$amt = (float)$g['amount'];
if ($amt > 0 && $adult && $payer && $adult !== $payer) {
$matrix[$adult][$payer] += $amt;
}
}
$settlements = [];
$countPeople = count($people);
for ($i = 0; $i < $countPeople; $i++) {
for ($j = $i + 1; $j < $countPeople; $j++) {
$a = $people[$i]; $b = $people[$j];
$net = $matrix[$a][$b] - $matrix[$b][$a];
if ($net > 0.01) { $settlements[] = ['from' => $a, 'to' => $b, 'amount' => $net]; }
elseif ($net < -0.01) { $settlements[] = ['from' => $b, 'to' => $a, 'amount' => -$net]; }
}
}
require __DIR__ . '/header.php';
?>
<div class="pf-container cl-view-<?= htmlspecialchars($currentView) ?>">
<div class="cl-titlebar">
<h1>🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
<div class="cl-view-switch">
<a href="?view=nadal" class="cl-view-btn <?= $currentView === 'nadal' ? 'is-active' : '' ?>"><?= tr('gift_view_nadal') ?></a>
<a href="?view=anniversary" class="cl-view-btn <?= $currentView === 'anniversary' ? 'is-active' : '' ?>"><?= tr('gift_view_anniv') ?></a>
</div>
</div>
<div class="pf-filter-bar">
<span style="font-size:1.2rem;">🔍</span>
<div class="pf-multi-select" id="ms-child">
<div class="pf-ms-trigger" onclick="toggleMS('ms-child-list', this)">
👦 <span id="ms-child-label"><?= tr('gift_filter_all_children') ?></span>
</div>
<div class="pf-ms-dropdown" id="ms-child-list">
<label class="pf-ms-option is-all">
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'child')">
<?= tr('gift_filter_all_children') ?>
</label>
<?php foreach($children as $c): ?>
<label class="pf-ms-option"><input type="checkbox" value="<?= htmlspecialchars($c) ?>" onchange="handleMSChange(this, 'child')"> <?= htmlspecialchars($c) ?></label>
<?php endforeach; ?>
</div>
</div>
<div class="pf-multi-select" id="ms-adult">
<div class="pf-ms-trigger" onclick="toggleMS('ms-adult-list', this)">
👤 <span id="ms-adult-label"><?= tr('gift_filter_all_adults') ?></span>
</div>
<div class="pf-ms-dropdown" id="ms-adult-list">
<label class="pf-ms-option is-all">
<input type="checkbox" value="all" checked onchange="handleMSChange(this, 'adult')">
<?= tr('gift_filter_all_adults') ?>
</label>
<?php foreach($allAdultsList as $a): ?>
<label class="pf-ms-option"><input type="checkbox" value="<?= htmlspecialchars($a) ?>" onchange="handleMSChange(this, 'adult')"> <?= htmlspecialchars($a) ?></label>
<?php endforeach; ?>
</div>
</div>
</div>
<section class="pf-section">
<?php foreach ($allowedOccasions as $occCode): ?>
<div class="js-occ-section">
<h2 class="cl-occasion-title">
<?php if(!empty($occasionIcons[$occCode])): ?><img src="<?= $occasionIcons[$occCode] ?>" class="cl-occasion-icon"><?php endif; ?>
<?= $allOccasionLabels[$occCode] ?>
</h2>
<?php foreach ($children as $child):
$childData = $data[$occCode][$child] ?? ['gifts' => [], 'totals' => []];
$adultsForThisChild = ($currentView === 'anniversary' && in_array($child, ['Pol', 'Pep'])) ? array_merge($baseAdults, $extraAdults) : $baseAdults;
?>
<div class="pf-child-section js-child" data-name="<?= htmlspecialchars($child) ?>">
<div class="pf-child-header">
<h3>👦 <?= htmlspecialchars($child) ?></h3>
<button class="pf-btn pf-btn-small btn-add-gift"
data-child="<?= htmlspecialchars($child) ?>"
data-occ="<?= htmlspecialchars($occCode) ?>"
data-adults="<?= htmlspecialchars(json_encode(array_values($adultsForThisChild))) ?>">
<?= tr('gift_add_gift') ?>
</button>
</div>
<div class="pf-child-totals-bar">
<?php foreach ($childData['totals'] as $adult => $tot): ?>
<span class="pf-summary-pill js-pill-adult" data-adult="<?= htmlspecialchars($adult) ?>">👤 <?= htmlspecialchars($adult) ?> : <strong><?= number_format($tot, 2, ',', '') ?> €</strong></span>
<?php endforeach; ?>
</div>
<?php if (empty($childData['gifts'])): ?>
<p class="js-empty-state" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; margin:0;"><?= tr('gift_empty_state_no_gifts') ?></p>
<?php else: ?>
<p class="js-empty-state" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; display:none; margin:0;"><?= tr('gift_empty_state_no_filter') ?></p>
<div class="pf-gift-feed">
<?php foreach ($childData['gifts'] as $g): ?>
<div class="pf-gift-card-compact js-gift-card" data-adult="<?= htmlspecialchars($g['adult_name']) ?>">
<div>
<h4 class="pf-gift-title">
<?= htmlspecialchars($g['gift_description']) ?>
<?php if($g['product_link']): ?><a href="<?= htmlspecialchars($g['product_link']) ?>" target="_blank" class="pf-gift-link">🔗</a><?php endif; ?>
</h4>
<span class="pf-pill-adult">👤 <?= htmlspecialchars($g['adult_name']) ?></span>
<?php if($g['payer_name'] && $g['payer_name'] !== $g['adult_name']): ?>
<div class="pf-gift-payer"><?= sprintf(tr('gift_paid_by'), htmlspecialchars($g['payer_name'])) ?></div>
<?php endif; ?>
</div>
<div class="pf-gift-footer">
<span class="pf-gift-price"><?= number_format($g['amount'], 2, ',', ' ') ?> €</span>
<div class="pf-gift-actions">
<button class="btn-icon-action edit btn-edit-gift" data-gift="<?= htmlspecialchars(json_encode($g)) ?>">✏️</button>
<button class="btn-icon-action delete btn-delete-gift" data-id="<?= $g['id'] ?>">🗑️</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</section>
<section class="pf-section pf-section--panel" style="border:1px solid var(--border-light); margin-top:40px;">
<h2 style="margin-top:0; color:var(--text-main); font-size:1.3rem;">⚖️ <?= tr('gift_liquidations') ?? 'Bilan & Remboursements' ?></h2>
<?php if (empty($settlements)): ?>
<p style="color:var(--success); font-weight:700; margin-bottom:0;">✅ <?= tr('gift_no_debt') ?></p>
<?php else: ?>
<ul class="pf-tricount-list">
<?php foreach ($settlements as $s): ?>
<li class="pf-tricount-item">
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> à <?= htmlspecialchars($s['to']) ?></span>
<strong style="color:var(--danger);"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<details style="margin-top:20px; padding:12px; border-radius:8px;">
<summary style="cursor:pointer; font-weight:600; color:var(--text-muted); outline:none;"><?= tr('gift_view_matrix') ?></summary>
<div style="overflow-x:auto; margin-top:15px;">
<table class="pf-table pf-table--compact cl-debt-matrix">
<thead>
<tr>
<th style="position:sticky; left:0; background:#f8fafc; z-index:2; border-right:2px solid #e2e8f0;"><?= tr('gift_debtor') ?> \ <?= tr('gift_creditor') ?></th>
<?php foreach ($people as $p): ?><th><?= htmlspecialchars($p) ?></th><?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($people as $debtor): ?>
<tr>
<th style="position:sticky; left:0; background:white; z-index:2; border-right:2px solid #e2e8f0;"><?= htmlspecialchars($debtor) ?></th>
<?php foreach ($people as $creditor):
$val = $matrix[$debtor][$creditor] ?? 0;
$display = ($debtor === $creditor) || $val == 0 ? '—' : number_format($val, 2, ',', ' ') . ' €';
?>
<td style="<?= $val > 0 ? 'color:var(--danger); font-weight:700; background:#fef2f2;' : 'color:var(--text-muted);' ?>"><?= $display ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</section>
</div>
<div id="pf-gift-modal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 500px; width: 95%;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="modalTitle" class="pf-modal-title" style="margin:0; border:none; padding:0;">Cadeau</h3>
<button type="button" class="btn-modal-close" style="background:none; border:none; font-size:1.8rem; cursor:pointer; color:var(--text-muted); line-height:1;">&times;</button>
</div>
<form method="post" action="/modules/gift-list/save-gift.php" id="giftForm">
<input type="hidden" name="year" value="<?= $year ?>">
<input type="hidden" name="child_name" id="modalChild">
<input type="hidden" name="occasion" id="modalOccasion">
<input type="hidden" name="action" id="modalAction">
<input type="hidden" name="gift_id" id="modalGiftId">
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_col_adult') ?></label><select name="adult_name" id="modalAdult" class="pf-input" required></select></div>
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_payer') ?></label><select name="payer_name" id="modalPayer" class="pf-input" required></select></div>
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_gift_name') ?></label><input type="text" name="gift_description" id="modalDesc" class="pf-input" required></div>
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_price') ?></label><input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input"></div>
<div class="pf-form-group" style="margin-bottom:25px;"><label class="pf-label"><?= tr('gift_modal_link') ?></label><input type="url" name="product_link" id="modalLink" class="pf-input"></div>
<div class="modal-footer" style="padding-top:15px; display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="pf-btn btn-secondary btn-modal-close"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
<script>
// ==========================================
// 1. GESTION DU COMPOSANT MULTI-SELECT VANILLA
// ==========================================
function toggleMS(listId, triggerEl) {
document.querySelectorAll('.pf-ms-dropdown').forEach(el => { if (el.id !== listId) el.classList.remove('open'); });
document.querySelectorAll('.pf-ms-trigger').forEach(el => { if (el !== triggerEl) el.classList.remove('active'); });
const list = document.getElementById(listId);
if (list) list.classList.toggle('open');
if (triggerEl) triggerEl.classList.toggle('active');
}
document.addEventListener('click', e => {
if (!e.target.closest('.pf-multi-select')) {
document.querySelectorAll('.pf-ms-dropdown').forEach(el => el.classList.remove('open'));
document.querySelectorAll('.pf-ms-trigger').forEach(el => el.classList.remove('active'));
}
});
function handleMSChange(cb, type) {
const container = document.getElementById('ms-' + type + '-list');
if (!container) return;
if (cb.value === 'all' && cb.checked) {
container.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(i => i.checked = false);
} else if (cb.checked) {
const allCb = container.querySelector('input[value="all"]');
if (allCb) allCb.checked = false;
} else {
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
if (checkedSpecifics.length === 0) {
const allCb = container.querySelector('input[value="all"]');
if (allCb) allCb.checked = true;
}
}
const checkedSpecifics = container.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])');
const labelEl = document.getElementById('ms-' + type + '-label');
if (labelEl) {
if (checkedSpecifics.length === 0) {
let defaultText = 'Tous';
if (window.I18N) {
if (type === 'child' && window.I18N['gift_filter_all_children']) defaultText = window.I18N['gift_filter_all_children'];
if (type === 'adult' && window.I18N['gift_filter_all_adults']) defaultText = window.I18N['gift_filter_all_adults'];
}
labelEl.innerText = defaultText;
} else if (checkedSpecifics.length === 1) {
labelEl.innerText = checkedSpecifics[0].value;
} else {
labelEl.innerText = checkedSpecifics.length + ' sélections';
}
}
applyGiftFilters();
}
function applyGiftFilters() {
const cList = document.getElementById('ms-child-list');
const aList = document.getElementById('ms-adult-list');
if (!cList || !aList) return;
const allChildCb = cList.querySelector('input[value="all"]');
const cAll = allChildCb ? allChildCb.checked : true;
const cVals = Array.from(cList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
const allAdultCb = aList.querySelector('input[value="all"]');
const aAll = allAdultCb ? allAdultCb.checked : true;
const aVals = Array.from(aList.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])')).map(i => i.value);
document.querySelectorAll('.js-occ-section').forEach(occSec => {
let occHasVisible = false;
occSec.querySelectorAll('.js-child').forEach(childSec => {
const cName = childSec.getAttribute('data-name');
const matchChild = cAll || (cVals.indexOf(cName) !== -1);
let childHasVisibleCard = false;
if (matchChild) {
childSec.querySelectorAll('.js-gift-card').forEach(card => {
const aName = card.getAttribute('data-adult');
const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
card.style.display = matchAdult ? 'flex' : 'none';
if (matchAdult) childHasVisibleCard = true;
});
childSec.querySelectorAll('.js-pill-adult').forEach(pill => {
const aName = pill.getAttribute('data-adult');
const matchAdult = aAll || (aVals.indexOf(aName) !== -1);
pill.style.opacity = matchAdult ? '1' : '0.2';
});
const empty = childSec.querySelector('.js-empty-state');
if (empty) empty.style.display = childHasVisibleCard ? 'none' : 'block';
childSec.style.display = 'block';
occHasVisible = true;
} else {
childSec.style.display = 'none';
}
});
occSec.style.display = occHasVisible ? 'block' : 'none';
});
}
// ==========================================
// 3. LOGIQUE DE LA MODALE & DELEGATION JS
// ==========================================
const modal = document.getElementById('pf-gift-modal');
const adultSelect = document.getElementById('modalAdult');
const payerSelect = document.getElementById('modalPayer');
// Synchronisation automatique : Adulte -> Payeur
if (adultSelect && payerSelect) {
adultSelect.addEventListener('change', function() {
let exists = false;
for(let i = 0; i < payerSelect.options.length; i++) {
if(payerSelect.options[i].value === this.value) exists = true;
}
if (exists) payerSelect.value = this.value;
});
}
function populateSelects(adults) {
if (!adultSelect || !payerSelect) return;
adultSelect.innerHTML = '';
payerSelect.innerHTML = '';
adults.forEach(name => {
adultSelect.appendChild(new Option(name, name));
payerSelect.appendChild(new Option(name, name));
});
}
// L'écouteur d'événements global pour tous les boutons !
document.body.addEventListener('click', async function(e) {
// --- 1. BOUTON FERMER MODALE ---
if (e.target.closest('.btn-modal-close')) {
if (modal) {
modal.classList.remove('open');
document.body.classList.remove('no-scroll');
}
return;
}
// --- 2. BOUTON AJOUTER (+) ---
const btnAdd = e.target.closest('.btn-add-gift');
if (btnAdd) {
const childName = btnAdd.dataset.child;
const occCode = btnAdd.dataset.occ;
const adultsList = JSON.parse(btnAdd.dataset.adults || '[]');
document.getElementById('modalAction').value = 'create';
document.getElementById('modalGiftId').value = '';
document.getElementById('modalChild').value = childName;
document.getElementById('modalOccasion').value = occCode;
document.getElementById('modalDesc').value = '';
document.getElementById('modalAmount').value = '';
document.getElementById('modalLink').value = '';
populateSelects(adultsList);
if (adultSelect && payerSelect) {
if (adultsList.indexOf('Laia') !== -1) {
adultSelect.value = 'Laia'; payerSelect.value = 'Laia';
} else if (adultsList.length > 0) {
adultSelect.value = adultsList[0]; payerSelect.value = adultsList[0];
}
}
const titleStr = window.I18N && window.I18N['gift_modal_title_add'] ? window.I18N['gift_modal_title_add'] : 'Ajouter pour %s';
document.getElementById('modalTitle').textContent = titleStr.replace('%s', childName);
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
return;
}
// --- 3. BOUTON MODIFIER (CRAYON) ---
const btnEdit = e.target.closest('.btn-edit-gift');
if (btnEdit) {
const data = JSON.parse(btnEdit.dataset.gift || '{}');
document.getElementById('modalAction').value = 'update';
document.getElementById('modalGiftId').value = data.id;
document.getElementById('modalChild').value = data.child_name;
document.getElementById('modalOccasion').value = data.occasion;
document.getElementById('modalDesc').value = data.gift_description;
document.getElementById('modalAmount').value = data.amount;
document.getElementById('modalLink').value = data.product_link;
const payerVal = data.payer_name || data.adult_name;
if (adultSelect && payerSelect) {
let adultExists = false, payerExists = false;
for(let i=0; i<adultSelect.options.length; i++) { if(adultSelect.options[i].value === data.adult_name) adultExists = true; }
for(let i=0; i<payerSelect.options.length; i++) { if(payerSelect.options[i].value === payerVal) payerExists = true; }
if (!adultExists) adultSelect.appendChild(new Option(data.adult_name, data.adult_name));
if (!payerExists) payerSelect.appendChild(new Option(payerVal, payerVal));
adultSelect.value = data.adult_name;
payerSelect.value = payerVal;
}
document.getElementById('modalTitle').textContent = window.I18N && window.I18N['gift_modal_title_edit'] ? window.I18N['gift_modal_title_edit'] : 'Modifier le cadeau';
if (modal) { modal.classList.add('open'); document.body.classList.add('no-scroll'); }
return;
}
// --- 4. BOUTON SUPPRIMER (POUBELLE) ---
const btnDel = e.target.closest('.btn-delete-gift');
if (btnDel) {
const giftId = btnDel.dataset.id;
const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?';
if (!confirm(msg)) return;
const fd = new FormData();
fd.append('action', 'delete');
fd.append('gift_id', giftId);
try {
await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd });
window.location.reload();
} catch(err) {
console.error(err);
}
return;
}
});
// ==========================================
// 4. SOUMISSION AJAX DU FORMULAIRE
// ==========================================
const giftForm = document.getElementById('giftForm');
if (giftForm) {
giftForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
const oldText = btn.innerText;
btn.innerText = '...';
btn.disabled = true;
try {
await fetch(e.target.getAttribute('action'), { method: 'POST', body: new FormData(e.target) });
window.location.reload();
} catch(err) {
console.error(err);
btn.innerText = oldText;
btn.disabled = false;
}
});
}
</script>
<?php require __DIR__ . '/footer.php'; ?>
+1059
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_groceries_name') . ' — HouseHub';
$activePage = 'groceries';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/groceries/assets/groceries.css">
<div id="groceries-toasts" class="groceries-toast-container"></div>
<div class="groceries-layout">
<div class="groceries-hero">
<h1>🛒 <?= htmlspecialchars(tr('mod_groceries_name')) ?></h1>
<p><?= htmlspecialchars(tr('mod_groceries_desc')) ?></p>
<p id="groceries-subtitle" class="pf-muted-note" style="margin-top:0.5rem;font-size:0.85rem"></p>
</div>
<div class="groceries-add-card">
<div class="groceries-add-card-inner">
<div class="groceries-quick-add">
<input type="text" id="groceries-new" class="pf-input groceries-add-input" placeholder="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>" autocomplete="off" maxlength="500" aria-label="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>">
<button type="button" class="pf-btn groceries-add-submit" onclick="addFromInput()"><?= htmlspecialchars(tr('groceries_btn_add')) ?></button>
</div>
</div>
</div>
<div class="groceries-toolbar">
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--outline" onclick="clearWholeList()"><?= htmlspecialchars(tr('groceries_btn_clear_list')) ?></button>
</div>
<div id="groceries-lists"></div>
<div class="groceries-history-card" id="groceries-history-root" aria-live="polite"></div>
</div>
<script src="/modules/groceries/assets/groceries.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+240 -19
View File
@@ -1,29 +1,250 @@
<?php
if (!isset($pageTitle)) {
$pageTitle = "PachaFamily";
}
if (!isset($activePage)) {
$activePage = "";
if (session_status() === PHP_SESSION_NONE) session_start();
// 1. Inclusion du moteur de traduction
require_once __DIR__ . '/includes/i18n.php';
/**
* Génère une URL avec le paramètre de langue mis à jour
*/
function getLangUrl($newLang) {
$params = $_GET;
$params['lang'] = $newLang;
return '?' . http_build_query($params);
}
$pageTitle = $pageTitle ?? "HouseHub";
$activePage = $activePage ?? "";
$currentLang = $_SESSION['app_lang'] ?? 'fr';
?>
<!DOCTYPE html>
<html lang="fr">
<html lang="<?= htmlspecialchars($currentLang) ?>">
<head>
<meta charset="UTF-8" />
<title><?php echo htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8'); ?></title>
<link rel="stylesheet" href="/assets/css/style.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<link rel="stylesheet" href="/global.css">
<?php if (!empty($pageCss)): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($pageCss) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/dark-mode.css">
<script>(function(){const t=localStorage.getItem('hh-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark');})()</script>
<style>
/* Patch CSS pour la zone droite de l'en-tête (Desktop / Mobile) */
.pf-header-right { display: flex; align-items: center; gap: 15px; }
@media (max-width: 768px) {
.pf-desktop-actions { display: none !important; }
}
</style>
</head>
<body>
<body data-page="<?= htmlspecialchars($activePage ?? '') ?>">
<header class="pf-header">
<div class="pf-container pf-header-content">
<div class="pf-logo">PachaFamily</div>
<nav class="pf-nav">
<a href="/index.php"
class="pf-nav-link <?php echo $activePage === 'home' ? 'pf-nav-link--active' : ''; ?>">Accueil</a>
<a href="/family-calendar.php"
class="pf-nav-link <?php echo $activePage === 'family-calendar' ? 'pf-nav-link--active' : ''; ?>">Family calendar</a>
</nav>
<a href="/index.php" class="pf-logo">HouseHub</a>
<?php if (isset($_SESSION['user'])): ?>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios']; ?>
<nav class="pf-nav">
<a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a>
<?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?>
<?php if (in_array('budget', $mods)): ?><a href="/budget.php" class="pf-nav-link <?= $activePage === 'budget' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_budget') ?></a><?php endif; ?>
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
</nav>
<?php endif; ?>
<div class="pf-header-right">
<button class="theme-switch" id="theme-toggle" onclick="toggleTheme()" title="Thème sombre / clair" aria-label="Basculer le thème">
<span class="ts-sun" aria-hidden="true">☀️</span>
<span class="ts-track"><span class="ts-thumb"></span></span>
<span class="ts-moon" aria-hidden="true">🌙</span>
</button>
<?php if (isset($_SESSION['user'])): ?>
<div class="pf-desktop-actions" style="display: flex; align-items: center; gap: 10px; border-left: 1px solid #cbd5e1; padding-left: 15px;">
<a href="/settings.php" class="pf-user-badge" style="text-decoration:none;color:inherit" title="Paramètres">
⚙️ <?= htmlspecialchars($_SESSION['user']['display_name'] ?? $_SESSION['user']['username']) ?>
</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
<a href="/admin/" class="pf-nav-link" style="font-size:0.85rem;color:#2563eb" title="Admin">Admin</a>
<?php endif; ?>
<a href="/logout.php" class="pf-logout-btn"><?= tr('btn_logout') ?></a>
</div>
<button class="pf-burger-btn" aria-label="<?= tr('aria_menu') ?>" style="margin-left: 5px;">☰</button>
<?php else: ?>
<a href="/login.php" class="pf-nav-link" style="border-left: 1px solid #cbd5e1; padding-left: 15px; margin-left: 5px;"><?= tr('btn_login') ?></a>
<?php endif; ?>
</div>
</header>
<main class="pf-container pf-main">
<?php if (isset($_SESSION['user'])): ?>
<div class="pf-mobile-overlay" id="mobileOverlay"></div>
<div class="pf-mobile-menu" id="mobileMenu">
<div class="pf-mobile-menu-header">
<span class="pf-logo">HouseHub</span>
<button class="pf-mobile-close-btn" id="closeMobileMenu">&times;</button>
</div>
<div class="pf-mobile-menu-body">
<a href="/index.php" class="pf-mobile-nav-link">🏠 <?= tr('menu_home') ?></a>
<a href="/family-calendar.php" class="pf-mobile-nav-link">📅 <?= tr('menu_calendar') ?></a>
<?php if (in_array('budget', $mods)): ?><a href="/budget.php" class="pf-mobile-nav-link">💰 <?= tr('menu_budget') ?></a><?php endif; ?>
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
<?php endif; ?>
<a href="/logout.php" class="pf-mobile-nav-link pf-mobile-logout" style="margin-top: auto;">🚪 <?= tr('btn_logout') ?></a>
</div>
</div>
<?php endif; ?>
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
<script>
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
/**
* Pont d'Internationalisation et Configuration
*/
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
// ✅ Injection sécurisée (évite le crash si config.php n'est pas chargé)
window.CONFIG = {
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>,
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>,
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
};
function tr(key) {
return window.I18N[key] || key;
}
// Thème clair/sombre — état visuel géré entièrement par CSS via [data-theme="dark"]
function toggleTheme(){
const dark=document.documentElement.getAttribute('data-theme')==='dark';
document.documentElement.setAttribute('data-theme',dark?'light':'dark');
localStorage.setItem('hh-theme',dark?'light':'dark');
}
// Gestion du menu mobile (Off-Canvas Sidebar)
<?php if (isset($_SESSION['user'])): ?>
document.addEventListener('DOMContentLoaded', () => {
const burgerBtn = document.querySelector('.pf-burger-btn');
const mobileMenu = document.getElementById('mobileMenu');
const overlay = document.getElementById('mobileOverlay');
const closeBtn = document.getElementById('closeMobileMenu');
function openMenu() {
mobileMenu.classList.add('is-open');
overlay.classList.add('is-visible');
document.body.classList.add('no-scroll');
}
function closeMenu() {
mobileMenu.classList.remove('is-open');
overlay.classList.remove('is-visible');
document.body.classList.remove('no-scroll');
}
if(burgerBtn && mobileMenu && overlay && closeBtn) {
burgerBtn.addEventListener('click', openMenu);
closeBtn.addEventListener('click', closeMenu);
// Fermer le menu si on clique en dehors (sur l'overlay sombre)
overlay.addEventListener('click', closeMenu);
}
});
<?php endif; ?>
/**
* pachaFetch : Utilitaire de requête robuste
*/
async function pachaFetch(url, options = {}) {
const finalUrl = url.startsWith('/') ? url.substring(1) : url;
options.credentials = 'same-origin';
options.headers = {
...options.headers,
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
};
try {
const response = await fetch(finalUrl, options);
const rawText = await response.text();
try {
return JSON.parse(rawText);
} catch (jsonErr) {
console.error("Réponse corrompue (HTML reçu au lieu de JSON) :", rawText);
throw new Error("Erreur serveur : format JSON invalide.");
}
} catch (err) {
console.error(`Erreur pachaFetch [${finalUrl}] :`, err);
throw err;
}
}
/**
* UI Utility : Toasts (Notifications)
*/
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `pf-toast pf-toast--${type}`;
toast.innerHTML = message;
document.body.appendChild(toast);
// Animation et suppression
setTimeout(() => toast.classList.add('is-visible'), 100);
setTimeout(() => {
toast.classList.remove('is-visible');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* UI Utility : Confirmation stylisée (Remplace confirm())
*/
async function pachaConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'pf-modal open';
modal.innerHTML = `
<div class="pf-modal-content" style="max-width: 400px; align-self: center;">
<h3 style="margin-top:0;">${title}</h3>
<p style="color:var(--text-muted);">${message}</p>
<div class="modal-footer">
<button class="pf-btn btn-secondary" id="confirm-cancel">${tr('btn_cancel')}</button>
<button class="pf-btn" id="confirm-ok" style="background:var(--danger);">${tr('btn_delete')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('confirm-cancel').onclick = () => { modal.remove(); resolve(false); };
document.getElementById('confirm-ok').onclick = () => { modal.remove(); resolve(true); };
});
}
</script>
+37
View File
@@ -0,0 +1,37 @@
<?php
// holidays.php
require __DIR__ . '/includes/auth.php';
require_login('/login.php');
require __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/i18n.php'; // Toujours s'assurer de l'accès à tr()
if (session_status() === PHP_SESSION_NONE) { session_start(); }
// 1. On récupère la vue demandée (par défaut 'list')
$tab = $_GET['tab'] ?? 'list';
// 2. Configuration des variables pour le header (Titres traduits)
$pageTitle = ($tab === 'holiday_detail')
? tr('hdl_title_detail')
: tr('hdl_title_list');
$activePage = "holidays";
$mainClass = "pf-holidays";
$pageCss = "/modules/holidays/holidays.css";
require __DIR__ . '/header.php';
// 3. ROUTEUR DU MODULE VACANCES
if ($tab === 'holiday_detail' && isset($_GET['id'])) {
// Si on demande le détail ET qu'un ID est fourni
require __DIR__ . '/modules/holidays/views/detail.php';
} else {
// Vue par défaut : la liste des cartes
require __DIR__ . '/modules/holidays/views/list.php';
}
// 4. Inclusion du JS global du module (Pont i18n déjà géré dans le header)
echo '<script src="/modules/holidays/holidays.js"></script>';
require __DIR__ . '/footer.php';
+18
View File
@@ -0,0 +1,18 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
$family_id = $_SESSION['user']['family_id'];
if (!$family_id) { http_response_code(404); exit; }
$file = null;
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $file = $f; break; }
if (!$file || !file_exists($file)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$mime = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','webp'=>'image/webp'][$ext] ?? 'image/jpeg';
header('Content-Type: ' . $mime);
header('Cache-Control: private, max-age=3600');
readfile($file);
+16028
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/auth.php';
require_login();
if (empty($_SESSION['user']['is_admin'])) {
http_response_code(403);
die('<h1>403 — Accès réservé aux administrateurs</h1><a href="/">Retour</a>');
}
+56
View File
@@ -0,0 +1,56 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
/**
* Vérifie qu'un utilisateur est connecté.
* Si non, le redirige vers la page de login avec ?redirect=URL_DEMANDEE
*
* @param string|null $loginPage URL de la page de login (par défaut /login.php)
*/
function require_login(?string $loginPage = '/login.php'): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['user'])) {
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false)) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Session expirée. Veuillez vous reconnecter.']);
exit;
}
$currentUrl = $_SERVER['REQUEST_URI'] ?? '/';
$redirectParam = urlencode($currentUrl);
$target = ($loginPage ?? '/login.php') . '?redirect=' . $redirectParam;
header('Location: ' . $target);
exit;
}
}
function csrf_token(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(?string $token): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || !$token) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
+7
View File
@@ -0,0 +1,7 @@
<?php
// includes/config.php
// HouseHub Config - Version dynamique
if (!defined('ID_ALEX')) {
define('ID_ALEX', 2);
define('ID_LAIA', 3);
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* Matériau de clé pour chiffrer les secrets (mot de passe dapp iCloud, etc.).
* Ordre de priorité :
* 1. Variable denvironnement APP_SECRET_KEY (recommandé en production)
* 2. Fichier data/.hh_app_secret (généré automatiquement au premier besoin si le dossier est inscriptible)
* 3. Dérivation stable depuis DB_PASS + DB_HOST (évite lerreur si rien nest configuré ; change si le mot de passe DB change)
*/
function hh_secret_key_material(): string
{
$fromEnv = trim((string) getenv('APP_SECRET_KEY'));
if ($fromEnv !== '') {
return $fromEnv;
}
$keyFile = dirname(__DIR__) . '/data/.hh_app_secret';
if (is_readable($keyFile)) {
$content = trim((string) file_get_contents($keyFile));
if ($content !== '' && strlen($content) >= 32) {
return $content;
}
}
$dir = dirname($keyFile);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
if (is_dir($dir) && is_writable($dir)) {
try {
$material = bin2hex(random_bytes(32));
if (@file_put_contents($keyFile, $material, LOCK_EX) !== false) {
@chmod($keyFile, 0600);
return $material;
}
} catch (\Throwable $e) {
// continuer vers la dérivation
}
}
$dbPass = (string) (getenv('DB_PASS') ?: '');
$dbHost = (string) (getenv('DB_HOST') ?: '');
return 'hh-derived|' . $dbPass . '|' . $dbHost . '|HouseHub-calendar-ios-v1';
}
function hh_encryption_key(): string
{
return hash('sha256', hh_secret_key_material(), true);
}
function hh_encrypt_secret(string $plain): string
{
$key = hh_encryption_key();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) {
throw new RuntimeException('Chiffrement impossible');
}
return base64_encode($iv . $cipher);
}
function hh_decrypt_secret(string $encrypted): string
{
$key = hh_encryption_key();
$raw = base64_decode($encrypted, true);
if ($raw === false || strlen($raw) <= 16) {
throw new RuntimeException('Secret invalide');
}
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($plain === false) {
throw new RuntimeException('Déchiffrement impossible');
}
return $plain;
}
+46 -44
View File
@@ -1,54 +1,56 @@
<?php
// Activer erreurs
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Détection de l'environnement
$isLocal = (strpos($_SERVER['HTTP_HOST'] ?? 'localhost', 'localhost') !== false ||
strpos($_SERVER['HTTP_HOST'] ?? '', '127.0.0.1') !== false ||
strpos($_SERVER['HTTP_HOST'] ?? '', '::1') !== false);
if ($isLocal) {
// Configuration XAMPP local
$host = 'localhost';
$db = 'percolo314'; // Même nom que sur OVH pour simplifier
$user = 'root';
$pass = ''; // Généralement vide sur XAMPP
} else {
// Configuration serveur OVH
$host = 'percolo314.mysql.db';
$db = 'percolo314';
$user = 'percolo314';
$pass = 'Wxcvbn99';
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
}
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
];
$host = getenv('DB_HOST') ?: 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
$db = $_SESSION['family_db'] ?? null;
if (!$db) {
$current = basename($_SERVER['PHP_SELF'] ?? '');
if (!in_array($current, ['login.php', 'register.php'])) {
header('Location: /login.php');
exit;
}
return;
}
try {
$pdo = new PDO($dsn, $user, $pass, $options);
// Forcer la collation
$pdo = new PDO(
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
PDO::ATTR_TIMEOUT => 30,
]
);
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
$pdo->exec("SET collation_database = utf8mb4_general_ci");
$pdo->exec("SET collation_server = utf8mb4_general_ci");
// Debug optionnel (à commenter en production)
// $env = $isLocal ? 'LOCAL' : 'SERVEUR';
// echo "<!-- Connecté en $env sur $host/$db -->";
try {
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
if (!defined('CURRENCY')) {
define('CURRENCY', $foyer['currency'] ?? '€');
}
if (!defined('ZONE_SCOLAIRE')) {
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C');
}
} catch (\PDOException $e) {
if (!defined('CURRENCY')) define('CURRENCY', '€');
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
}
// ------------------------------------------------
} catch (\PDOException $e) {
$environment = $isLocal ? 'local (XAMPP)' : 'serveur (OVH)';
die("Erreur de connexion ($environment) : " . $e->getMessage());
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
}
?>
+32
View File
@@ -0,0 +1,32 @@
<?php
// Démarrer la session si ce n'est pas déjà fait (souvent fait dans auth.php, mais on sécurise)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 1. Changement de langue si l'utilisateur a cliqué sur un drapeau
if (isset($_GET['lang']) && in_array($_GET['lang'], ['fr', 'ca', 'en'])) {
$_SESSION['app_lang'] = $_GET['lang'];
}
// 2. Langue par défaut = Français
$current_lang = $_SESSION['app_lang'] ?? 'fr';
// 3. Chargement du bon dictionnaire
$lang_file = __DIR__ . '/lang/' . $current_lang . '.php';
if (file_exists($lang_file)) {
$GLOBALS['translations'] = include $lang_file;
} else {
$GLOBALS['translations'] = [];
}
// 4. La fonction magique de traduction
// Si le mot existe dans le dictionnaire on l'affiche, sinon on affiche la clé par défaut
if (!function_exists('tr')) {
function tr($key) {
return $GLOBALS['translations'][$key] ?? $key;
}
}
// Export pour le client (header.php → window.I18N) : pachaConfirm, modules JS, etc.
$current_translations_array = $GLOBALS['translations'] ?? [];
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* Normalise le mot de passe dapplication Apple (supprime les espaces, garde les tirets).
*/
function hh_normalize_apple_app_password(string $password): string
{
return preg_replace('/\s+/', '', trim($password));
}
function hh_caldav_url_is_icloud(string $url): bool
{
$h = strtolower((string) (parse_url(trim($url), PHP_URL_HOST) ?: ''));
return str_contains($h, 'icloud.com');
}
function hh_caldav_is_icloud_well_known_root(string $url): bool
{
$parts = parse_url(rtrim(trim($url), '/'));
if (empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
$host = strtolower($parts['host']);
if ($host !== 'caldav.icloud.com') {
return false;
}
$path = $parts['path'] ?? '';
return $path === '' || $path === '/';
}
function hh_caldav_resolve_href(string $againstUrl, string $href): string
{
$href = trim($href);
if ($href === '') {
return $againstUrl;
}
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
return $href;
}
$parts = parse_url($againstUrl);
$scheme = $parts['scheme'] ?? 'https';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
if ($href[0] === '/') {
return $scheme . '://' . $host . $port . $href;
}
$base = preg_replace('#/[^/]*$#', '/', $againstUrl);
return rtrim($base, '/') . '/' . ltrim($href, '/');
}
/**
* @return array{code:int, body:string}
*/
function hh_caldav_propfind(string $url, string $username, string $password, string $xml, string $depth = '0'): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CUSTOMREQUEST => 'PROPFIND',
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_HTTPHEADER => [
'Content-Type: application/xml; charset=utf-8',
'Depth: ' . $depth,
],
CURLOPT_POSTFIELDS => $xml,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false) {
return ['code' => 0, 'body' => ''];
}
return ['code' => $code, 'body' => $body];
}
function hh_caldav_first_href_by_element(string $xml, string $parentLocalName): ?string
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xml)) {
return null;
}
$xpath = new DOMXPath($dom);
$nodes = $xpath->query("//*[local-name()='{$parentLocalName}']//*[local-name()='href']");
if ($nodes->length === 0) {
return null;
}
return trim($nodes->item(0)->textContent);
}
/**
* @return list<array{href:string, display:string}>
*/
function hh_caldav_parse_calendar_collections(string $xml): array
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xml)) {
return [];
}
$xpath = new DOMXPath($dom);
$responses = $xpath->query("//*[local-name()='response']");
$out = [];
foreach ($responses as $resp) {
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
if ($hrefNodes->length === 0) {
continue;
}
$href = trim($hrefNodes->item(0)->textContent);
$cal = $xpath->query(".//*[local-name()='resourcetype']/*[local-name()='calendar']", $resp);
if ($cal->length === 0 || $href === '') {
continue;
}
$display = '';
$dn = $xpath->query(".//*[local-name()='displayname']", $resp);
if ($dn->length > 0) {
$display = trim($dn->item(0)->textContent);
}
$out[] = ['href' => $href, 'display' => $display];
}
return $out;
}
/**
* Liste les collections calendrier iCloud (URL complète avec / final + nom affiché).
*
* @return list<array{display:string, url:string}>
*
* @throws RuntimeException
*/
function hh_icloud_discover_calendar_entries(string $username, string $password): array
{
$password = hh_normalize_apple_app_password($password);
$root = 'https://caldav.icloud.com/';
$xml1 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:">'
. '<prop><current-user-principal/></prop>'
. '</propfind>';
$r1 = hh_caldav_propfind($root, $username, $password, $xml1, '0');
if ($r1['code'] === 401 || $r1['code'] === 403) {
throw new RuntimeException('Identifiant Apple ou mot de passe dapp refusé (HTTP ' . $r1['code'] . ').');
}
if (!in_array($r1['code'], [200, 207], true)) {
throw new RuntimeException('CalDAV racine inaccessible (HTTP ' . $r1['code'] . ').');
}
$principalHref = hh_caldav_first_href_by_element($r1['body'], 'current-user-principal');
if (!$principalHref) {
throw new RuntimeException('Réponse CalDAV inattendue : principal introuvable.');
}
$principalUrl = hh_caldav_resolve_href($root, $principalHref);
$xml2 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
. '<prop><C:calendar-home-set/></prop>'
. '</propfind>';
$r2 = hh_caldav_propfind($principalUrl, $username, $password, $xml2, '0');
if (!in_array($r2['code'], [200, 207], true)) {
throw new RuntimeException('Lecture du principal CalDAV impossible (HTTP ' . $r2['code'] . ').');
}
$homeHref = hh_caldav_first_href_by_element($r2['body'], 'calendar-home-set');
if (!$homeHref) {
throw new RuntimeException('Aucun dossier de calendriers (calendar-home-set) trouvé.');
}
$homeUrl = hh_caldav_resolve_href($principalUrl, $homeHref);
$xml3 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:">'
. '<prop><resourcetype/><displayname/></prop>'
. '</propfind>';
$r3 = hh_caldav_propfind($homeUrl, $username, $password, $xml3, '1');
if (!in_array($r3['code'], [200, 207], true)) {
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
}
$raw = hh_caldav_parse_calendar_collections($r3['body']);
if ($raw === []) {
throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.');
}
$out = [];
foreach ($raw as $e) {
$url = rtrim(hh_caldav_resolve_href($homeUrl, $e['href']), '/') . '/';
$out[] = ['display' => $e['display'], 'url' => $url];
}
return $out;
}
/**
* Depuis la racine iCloud (https://caldav.icloud.com), découvre lURL dun calendrier (par défaut « Calendar » / « Calendrier », sinon le premier).
*
* @throws RuntimeException
*/
function hh_icloud_discover_default_calendar_url(string $username, string $password): string
{
$entries = hh_icloud_discover_calendar_entries($username, $password);
$preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default'];
foreach ($entries as $e) {
$lower = mb_strtolower($e['display']);
foreach ($preferred as $p) {
if ($lower !== '' && str_contains($lower, $p)) {
return $e['url'];
}
}
}
return $entries[0]['url'];
}
/**
* Vérifie quune URL de collection calendrier répond en CalDAV (PROPFIND).
*/
function hh_caldav_test_calendar_collection(string $username, string $password, string $calendarUrl): int
{
$password = hh_normalize_apple_app_password($password);
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
. '<prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>'
. '</propfind>';
$r = hh_caldav_propfind(rtrim($calendarUrl, '/') . '/', $username, $password, $xml, '0');
return $r['code'];
}
/**
* Si URL = racine iCloud bien connue, découvre et retourne lURL dun calendrier ; sinon retourne lURL telle quelle (trim).
*
* @throws RuntimeException
*/
function hh_icloud_resolve_calendar_url_if_needed(string $username, string $password, string $calendarUrl): string
{
$calendarUrl = trim($calendarUrl);
if (hh_caldav_is_icloud_well_known_root($calendarUrl)) {
return hh_icloud_discover_default_calendar_url($username, $password);
}
return $calendarUrl;
}
+662
View File
@@ -0,0 +1,662 @@
<?php
// Dictionnaire Catalan
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Proves en curs...',
'test_passed' => 'Prova superada',
'test_failed' => 'Prova fallida',
'tests_title' => 'Laboratori de Proves',
'tests_run_budget' => 'Provar el Pressupost',
'tests_run_all' => 'Iniciar totes les proves',
'tests_copy_report' => 'Copiar l\'informe',
'tests_waiting' => 'Esperant l\'inici...',
'tests_report_copied' => 'Informe copiat al porta-retalls!',
'tests_live_preview' => 'Previsualització en viu',
// ==========================================
// GLOBAL & NAVIGATION
// ==========================================
'menu_home' => 'Inici',
'menu_calendar' => 'Calendari',
'menu_budget' => 'Pressupost',
'menu_holidays' => 'Viatges',
'menu_gifts' => 'Regals',
'menu_calendar_ios' => 'Calendari iOS',
'nav_home' => 'Inici',
'nav_calendar' => 'Calendari',
'nav_budget' => 'Pressupost',
'nav_holidays' => 'Viatges',
'nav_gifts' => 'Regals',
'aria_menu' => 'Menú mòbil',
// Boutons et actions globales
'actions' => 'Accions',
'edit' => 'Modificar',
'delete' => 'Suprimir',
'save' => 'Desar',
'cancel' => 'Cancel·lar',
'btn_save' => 'Guardar',
'btn_cancel' => 'Cancel·lar',
'btn_delete' => 'Eliminar',
'btn_close' => 'Tancar',
'btn_back' => '◀ Tornar',
'btn_edit_bases' => '⚙️ Modificar les bases',
'btn_edit' => 'Modificar',
'error_occured' => 'S\'ha produït un error.',
// Mots génériques & Erreurs
'error' => 'Error.',
'confirm_delete' => 'Estàs segur que vols suprimir aquest element?',
// Mois & Jours
'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març',
'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny',
'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre',
'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre',
'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc',
'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg',
// ==========================================
// AUTHENTIFICATION / LOGIN
// ==========================================
'login_title' => 'Connexió - HouseHub',
'login_header' => 'Connexió',
'label_username' => "Nom d'usuari",
'label_password' => 'Contrasenya',
'placeholder_username' => 'ex: pacha',
'btn_login' => 'Connexió',
'btn_login_submit' => 'Connectar-se',
'logout' => 'Tancar sessió',
'btn_logout' => 'Tancar sessió',
'error_missing_fields' => 'Camps obligatoris.',
'error_invalid_credentials' => 'Identificadors incorrectes.',
'login_subtitle' => 'Si us plau, identifiqueu-vos per continuar',
// ==========================================
// ACCUEIL / INDEX
// ==========================================
'home_title' => 'HouseHub - Inici',
'home_welcome' => 'Benvinguts a HouseHub',
'home_subtitle' => "Centre de control de l'organització familiar.",
'home_logged_as' => 'Connectat com a',
'home_modules_title' => 'Mòduls',
'mod_calendar_name' => 'Calendari Familiar',
'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.",
'mod_holidays_name' => 'Vacances',
'mod_holidays_desc' => 'Planifiqueu les vostres properes escapades, destinacions i idees de viatge.',
'mod_gifts_name' => 'Regals',
'mod_gifts_desc' => "Planifiqueu els regals per a cada infant, per Nadal i els aniversaris.",
'mod_budget_name' => 'Pressupost',
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
'mod_garage_name' => 'Garatge',
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Compra',
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
'menu_groceries' => 'Compra',
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
'groceries_btn_add' => 'Afegir',
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
'groceries_btn_delete_picked' => 'Eliminar els marcats',
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
'groceries_added' => 'Afegit',
'groceries_updated' => 'Actualitzat',
'groceries_deleted' => 'Eliminat',
'groceries_reset_next' => 'Llista llesta per a la propera compra',
'groceries_picked_removed' => 'Articles eliminats',
'groceries_label_empty' => 'El text no pot ser buit',
'groceries_confirm_del_title' => 'Eliminar?',
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
'groceries_confirm_clear_title' => 'Eliminar els marcats',
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
'groceries_section_pending' => 'Pendents',
'groceries_section_cart' => 'Al carretó',
'groceries_prompt_edit' => 'Editar el producte',
'groceries_btn_clear_list' => 'Buidar la llista',
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a lhistorial per tornar-los a afegir ràpidament.',
'groceries_list_cleared' => 'Llista buidada',
'groceries_history_title' => 'Historial — un clic per afegir',
'groceries_history_empty' => 'Lhistorial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
'groceries_history_meta' => 'Fins a {max} productes · ',
'groceries_settings_link' => 'Paràmetres',
'groceries_settings_title' => 'Compra — historial',
'groceries_settings_intro' => 'Nombre màxim de productes recordats a lhistorial (sense duplicats, sense distingir majúscules).',
'groceries_settings_history_max' => 'Mida de lhistorial',
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
'groceries_settings_saved' => 'Preferències de la compra desades.',
'groceries_settings_error' => 'No sha pogut desar:',
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
'cta_open' => 'Obrir',
'cta_explore' => 'Explorar',
'cta_view_lists' => 'Desembolicar',
'cta_manage' => 'Gestionar',
'cta_service' => 'Revisar',
'cta_jot' => 'Apuntar',
'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimir',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
'menu_planka' => 'Planka',
// ==========================================
// FAMILY CALENDAR
// ==========================================
'fc_page_title' => 'HouseHub - Calendari',
'fc_main_header' => 'Calendari Familiar',
'fc_btn_correct_balances' => 'Corregir els saldos',
'fc_btn_school_holidays' => 'Vacances escolars',
'fc_modal_holidays_title' => 'Vacances Escolars',
'fc_modal_snap_title' => 'Ajustar un saldo',
'fc_col_period' => 'Període',
'fc_col_from' => 'Des del',
'fc_col_to' => 'Fins al',
'fc_label_person' => 'Persona',
'fc_label_leave_type' => 'Tipus de permís',
'fc_label_apply_date' => "Data d'aplicació",
'fc_snap_date_hint' => "Trieu preferiblement el primer dia d'un mes",
'fc_label_remaining_balance'=> 'Saldo restant en aquesta data',
'fc_placeholder_balance' => 'ex: 12.5',
'fc_btn_save_snap' => 'Desar el correctiu',
'fc_view_1m' => '1 mes',
'fc_view_2m' => '2 mesos',
'fc_view_3m' => '3 mesos',
'fc_view_year' => 'Any',
'fc_weekly_planning' => 'Planificació setmanal',
'fc_col_month' => 'Mes',
'fc_col_week_short' => 'Set.',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Llegenda',
'fc_summary_title' => 'Resum',
'fc_summ_year' => 'Any',
'fc_summ_month' => 'Mes',
'fc_school_year' => 'Curs escolar',
'fc_unit_days' => 'dies',
'fc_menu_carole' => 'Permisos Carole',
'fc_clear' => 'Esborrar',
'fc_menu_kids_leaves' => 'Permisos nens',
'fc_err_save_snap' => 'Error en desar el correctiu.',
'fc_err_fetch_gov' => 'Error en la connexió amb l\'API governamental.',
'fc_err_no_data_gov' => 'No s\'han trobat dades a l\'API del govern per a aquest any.',
'fc_err_action' => 'Error en l\'acció: ',
'fc_today_short' => 'Avui',
'fc_today_title' => 'Anar a avui',
'leg_presence' => 'Presència Pep',
'leg_school_holidays' => 'Vacances Escolars',
'leg_public_holiday' => 'Festiu',
'leg_off_carole' => 'Off Carole',
'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Casal',
'leg_avis' => 'Avís',
'leg_pep_sick' => 'Pep Malalt',
'vac_toussaint' => 'Vacances de Tots Sants',
'vac_noel' => 'Vacances de Nadal',
'vac_hiver' => "Vacances d'Hivern",
'vac_printemps' => 'Vacances de Primavera',
'vac_ascension' => "Pont de l'Ascensió",
'vac_ete' => "Vacances d'Estiu",
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del %s',
'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!',
// ==========================================
// HOLIDAYS (VOYAGES)
// ==========================================
'hdl_title_list' => 'HouseHub - Les meves vacances',
'hdl_title_detail' => 'HouseHub - Detall del viatge',
'hdl_main_title' => 'Les meves vacances',
'hdl_filter_all' => 'Mostrar-ho tot',
'hdl_filter_year' => 'Any',
'hdl_left_to_pay_label' => 'Resta per pagar',
'hdl_btn_create' => 'Crear un viatge',
'hdl_no_active_trips' => 'Cap viatge en curs per aquest període. Planifiquem alguna cosa!',
'hdl_history_title' => 'Historial',
'hdl_dates_to_define' => 'Dates per definir',
'hdl_total_budget' => 'Pressupost Total',
'hdl_paid' => 'Pagat',
'hdl_saved' => 'Finançat',
'hdl_left_to_pay' => 'Resta per pagar',
'hdl_status_booked' => 'Confirmat',
'hdl_status_planned' => 'Planificat',
'hdl_status_draft' => 'Esborrany',
'hdl_status_passed' => 'Passat',
'hdl_status_archived' => 'Arxivat',
'hdl_modal_title' => 'Planificar el viatge',
'hdl_label_name' => 'Nom del viatge',
'hdl_ph_name' => 'Ex: Octubre - Portugal',
'hdl_label_status' => 'Estat',
'hdl_label_period' => 'Període (Text lliure)',
'hdl_ph_period' => 'Ex: Octubre 2026',
'hdl_label_from' => 'Des del',
'hdl_label_to' => 'Fins al',
'hdl_cat_transport' => 'Transport',
'hdl_add_transport' => 'Afegir un transport',
'hdl_cat_accommodation' => 'Allotjament',
'hdl_add_accommodation' => 'Afegir un allotjament',
'hdl_cat_activity' => 'Activitat',
'hdl_add_activity' => 'Afegir una activitat',
'hdl_label_budget_food' => 'Pressupost Menjar i Beguda (€)',
'hdl_label_budget_extras' => 'Pressupost Extras (€)',
'hdl_label_notes' => 'Notes',
'hdl_ph_notes' => 'Idees soltes...',
'hdl_label_budget_food_extras' => 'Pressupost Menjar i Extres',
'hdl_map_itinerary' => 'Itinerari',
'hdl_outbound' => 'Anada',
'hdl_return' => 'Tornada',
'hdl_btn_add_step' => 'Afegir una etapa',
'hdl_step_details' => 'Detalls del roadtrip',
'hdl_no_steps' => 'Cap etapa definida. Comenceu per afegir-ne una!',
'hdl_general_costs' => 'Despeses generals i Pressupostos',
'hdl_food_bev' => 'Menjar i Beguda',
'hdl_extras' => 'Extres',
'hdl_view_planning' => 'Veure la planificació',
'hdl_passing_point' => 'Punt de pas (sense despeses)',
'hdl_search_location' => 'Cercar un lloc',
'hdl_ph_search' => 'Ciutat, hotel, adreça...',
'hdl_label_step_name' => "Nom de l'etapa",
'hdl_label_arrival' => 'Arribada',
'hdl_label_departure' => 'Sortida',
'hdl_planned_expenses' => 'Despeses previstes per a aquesta etapa',
'hdl_btn_add_expense' => '+ Afegir una despesa',
'hdl_save_fav' => 'Desar als meus preferits',
'hdl_planning_title' => 'Planificació de l\'etapa',
'hdl_to_pay' => 'Per pagar',
'hdl_js_search_loading' => 'Cerca en curs...',
'hdl_js_no_result' => 'No s\'han trobat resultats.',
'hdl_js_network_error' => 'Error de connexió al servei de mapes.',
'hdl_js_confirm_del_trip' => 'Estàs segur que vols suprimir aquest viatge sencer?',
'hdl_js_confirm_del_step' => 'Suprimir aquesta etapa?',
'hdl_js_step_label' => 'Etapa',
'hdl_js_ph_expense_name' => 'Ex: Hotel, Peatge...',
'hdl_quick_edit_title' => 'Modificació ràpida',
'hdl_js_edit_step' => 'Modificar l\'etapa',
'hdl_js_missing_dates_title'=> 'Dates mancants',
'hdl_js_missing_dates_msg' => 'Cal definir una data d\'arribada i sortida per organitzar la planificació.',
'hdl_to_place' => 'Per situar',
'hdl_from' => 'Des del',
'hdl_to' => 'Fins al',
'hdl_btn_add_step' => 'Afegir una etapa',
'hdl_quick_edit_title' => 'Modificació ràpida',
'hdl_paid' => 'Pagat',
'weather_forecast' => 'Previsions meteorològiques',
'weather_not_available' => 'Temps no disponible',
'temp_max' => 'Temp. Màxima',
'weather_loading' => 'Carregant el temps...',
'weather_error' => 'Temps no disponible',
'weather_sunny' => 'Assolellat',
'weather_cloudy' => 'Ennuvolat',
'weather_rainy' => 'Plujós',
'weather_snowy' => 'Nevat',
'weather_historical' => 'Basat en l\'històric (estimació)',
'err_invalid_holiday_id' => 'ID de viatge no vàlid.',
'hdl_default_exp_name' => 'Despesa',
'hdl_select_beneficiary' => '-- Seleccionar --',
// Anciennes clés Holidays non préfixées (conservades)
'outbound_trip' => 'Anada',
'return_trip' => 'Tornada',
'period' => 'Període',
'budget_food' => 'Pressupost Menjar i Extres',
'total_cost' => 'Cost Total Estimat',
'paid' => 'Pagat',
'provisioned' => 'Provisionat',
'left_to_pay' => 'Pendent de pagament',
'map_itinerary' => '🗺️ Itinerari',
'place_step' => '📍 Afegir una etapa',
'step_details' => '📝 Detall de les etapes',
'no_steps' => 'Cap etapa planificada.',
'travel_notes' => '📝 Notes del viatge',
'to_be_defined' => 'A definir',
'funded' => 'Finançat (Provisió)',
'view_planning' => 'Veure el planning',
'to_pay' => 'A pagar',
'from_date' => 'Del',
'to_date' => 'al',
'passing_point' => 'Punt de pas (Cap despesa)',
'search_location' => 'Cercar una ubicació geogràfica',
'search_placeholder' => 'Ex: París, Ibis Barcelona...',
'step_name' => 'Nom de l\'etapa',
'arrival_optional' => 'Arribada el (Opcional)',
'departure_optional' => 'Sortida el (Opcional)',
'planned_expenses' => 'Despeses previstes',
'add_expense' => '+ Afegir una despesa',
'save_favorite' => 'Guardar als meus preferits',
'planning_title' => 'Planning',
'modal_plan_trip' => 'Planificar el viatge',
'modal_quick_edit' => 'Edició ràpida',
'err_modal_missing' => 'Error: La modal holidayModal no es troba.',
'placeholder_title' => 'Títol',
'placeholder_price' => 'Preu (€)',
'already_paid' => 'Ja pagat?',
'remove_line' => 'Treure aquesta línia',
'confirm_delete_trip' => 'Vols eliminar definitivament aquest viatge? Aquesta acció és irreversible.',
'step_number' => 'Etapa',
'place_new_step' => '📍 Afegir una nova etapa',
'edit_step' => '✏️ Modificar l\'etapa',
'search_in_progress' => 'Cerca en curs... ⏳',
'no_result_found' => 'Cap resultat trobat.',
'network_error' => 'Error de xarxa.',
'placeholder_label' => 'Etiqueta',
'placeholder_notes_link' => '🔗 Enllaç de reserva o notes (Opcional)...',
'confirm_delete_step' => 'Eliminar aquesta etapa i totes les despeses associades?',
'planning_of' => '📅 Planning : ',
'missing_dates_title' => '⚠️ Dates que falten',
'missing_dates_msg' => 'Si us plau, modifica aquesta etapa (✏️) i omple la seva <strong>Data d\'arribada i de sortida</strong>.',
'to_place' => '📥 A col·locar',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// ==========================================
'budget_page_title' => 'HouseHub - Pressupost',
'budget_main_header' => 'Gestió del Pressupost',
'budget_tab_tracking' => 'Seguiment Mensual',
'budget_tab_prev' => 'Pressupost 2026',
'budget_tab_savings' => 'Estalvis',
'budget_tab_recap' => 'Resum',
'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.',
'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.',
'budget_err_not_authorized' => 'Pàgina no autoritzada.',
'cat_income' => 'Ingressos',
'cat_fmcg' => 'Compres (FMCG)',
'cat_fuel' => 'Benzina',
'cat_school' => 'Escola / Llar d\'infants',
'cat_fixed' => 'Despeses Fixes',
'cat_others' => 'Altres / Imprevistos',
'cat_savings' => 'Estalvis',
// --- BUDGET : SUIVI ---
'bud_rem_school' => 'Escola (Resta estimada)',
'bud_rem_fmcg' => 'Compres (Resta estimada)',
'bud_rem_fuel' => 'Benzina (Resta estimada)',
'bud_closed_archived' => 'Aquest mes està tancat i arxivat.',
'bud_budget_of' => 'Pressupost:',
'bud_confirm_close' => 'Tancar aquest mes? Això congelarà els saldos actuals i canviarà el seguiment per defecte al mes vinent.',
'bud_close_btn' => 'Tancar',
'bud_confirm_reopen' => 'Reobrir aquest mes? Els seus saldos es recalcularan en directe.',
'bud_reopen_btn' => 'Reobrir',
'bud_active_month_btn' => 'Mes Actiu',
'bud_bank_balance' => 'Saldo bancari',
'bud_frozen' => 'Congelat',
'bud_current' => 'Actual',
'bud_update_btn' => 'Act.',
'bud_theoretical_balance' => 'Saldo teòric final de mes',
'bud_upcoming_charges' => 'Càrrecs pendents',
'bud_all_paid' => 'Tot està pagat!',
'bud_capacity_tooltip' => 'Càlcul: Report Mes Anterior (%s€) + Sous (%s€) + Altres ingressos (%s€)',
'bud_max_capacity' => 'Capacitat Màx',
'bud_actual' => 'Actual',
'bud_theoretical' => 'Teòric',
'bud_import_csv' => 'Importar CSV',
'bud_csv_file' => 'Fitxer CSV (Punt i coma)',
'bud_preview' => 'Previsualitzar',
'bud_validate_import' => 'Validar importació',
'bud_assign_month' => 'Assignar al mes',
'bud_op_date' => 'Data Operació',
'bud_label' => 'Concepte',
'bud_amount' => 'Import',
'bud_category' => 'Categoria',
'bud_ignore' => 'Ignorar',
'bud_to_define' => 'Per definir',
'bud_is_charge' => 'Despesa?',
'bud_is_income' => 'Ingrés?',
'btn_import' => 'Importar',
'bud_no_lines' => 'Cap línia.',
'bud_confirm_delete' => 'Suprimir aquesta línia?',
'bud_new_transaction' => 'Nova transacció',
'bud_assign_to_month' => 'Assignar al mes de gestió',
'bud_real_date' => 'Data real de l\'operació',
'bud_type' => 'Tipus',
'bud_type_expense' => 'Despesa',
'bud_type_income' => 'Ingrés / Reemborsament',
'bud_beneficiary' => 'Beneficiari',
'bud_fixed_charge' => 'Despesa Fixa',
'bud_expected_income' => 'Ingrés Esperat',
'bud_amount_eur' => 'Import (€)',
'bud_update_balance' => 'Act. Saldo Bancari',
'bud_balance_eur' => 'Saldo (€)',
'bud_add_title' => 'Afegir',
'bud_edit_title' => 'Modificar la transacció',
'bud_to_define_js' => 'per definir',
'bud_bar_actual' => "Actual",
'bud_bar_theoretical' => "Teòric",
'bud_bar_max_cap' => "Capacitat Màx.",
// --- MODULE BUDGET : IMPORT CSV ---
'bud_import_csv' => "Importar CSV",
'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)",
'bud_preview' => "Previsualitzar",
'bud_validate_import' => "Si us plau, comproveu i categoritzeu les línies abans de validar.",
'bud_assign_month' => "Mes de gestió",
'bud_op_date' => "Data",
'bud_amount' => "Import",
'bud_ignore' => "Ignorar (Crèdit)",
'bud_to_define' => "Per definir",
'bud_is_charge' => "És un càrrec fix...",
'bud_is_income' => "És un ingrés...",
// --- BUDGET : RECAP ---
'bud_recap_monthly_title' => 'Resum Mensual',
'bud_recap_btn_add' => 'Afegir una despesa / ingrés',
'bud_planned_amount' => 'Import Previst',
'bud_day' => 'Dia',
'bud_month_state' => 'Estat del mes',
'bud_est_short' => 'Est.',
'bud_reg_planned_in' => 'Regularització prevista al %s',
'bud_bonus' => 'Bonificació',
'bud_overrun' => 'Excés',
'bud_remaining' => 'Resta',
'bud_exact_amount' => 'Import exacte',
'bud_per_month_short' => 'És a dir',
'bud_month_short' => 'mes',
'bud_freq_mensuel' => 'Mensual',
'bud_freq_annuel' => 'Anual',
'bud_state_validated' => 'Validat',
'bud_state_partial' => 'Parcial',
'bud_state_waiting' => 'En espera',
'bud_total_income_smoothed' => 'Total Ingressos (Prorratejats)',
'bud_total_expenses_smoothed'=> 'Total Despeses (Prorratejades)',
'bud_theoretical_balance_recap'=> 'Equilibri teòric',
'bud_recap_footer_note' => 'L\'estat del pagament s\'actualitza automàticament en funció de les operacions importades a la pestanya "Seguiment Mensual".',
'bud_recap_modal_add' => 'Afegir un element',
'bud_recap_modal_edit' => 'Modificar',
'bud_label_name' => 'Nom',
'bud_ph_item_name' => 'Ex: Lloguer, Sou...',
'bud_label_keywords' => 'Paraules clau (Detecció Auto)',
'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (separades per coma)',
'bud_help_keywords' => 'Si una despesa del mes conté aquesta paraula, la línia passarà a "Validat".',
'bud_label_day' => 'Dia (1-31)',
'bud_cat_income' => 'Estalvi',
'bud_cat_expense' => 'Despesa',
'bud_label_frequency' => 'Freqüència',
'bud_label_amount_type' => 'Tipus d\'import',
'bud_type_fixed' => 'Fix (Factura)',
'bud_type_variable' => 'Variable (Estimació)',
'bud_label_regularization' => 'Regularització',
'bud_reg_none' => 'Cap',
'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?',
'bud_err_delete' => 'Error en suprimir.',
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Introduir un mes',
'bud_sav_add_one_month' => '+1 Mes',
'bud_sav_no_data' => 'Cap dada per a %s.',
'bud_sav_post_month' => 'Concepte / Mes',
'bud_sav_from_date' => 'Des del %s',
'bud_sav_edit_modal' => 'Modificar amb Modal',
'bud_sav_delete_month' => 'Suprimir tot el mes',
'bud_sav_total_bank' => 'Total Banc',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Introduir un mes',
'bud_sav_modal_title_edit' => 'Modificar:',
'bud_sav_month_concerned' => 'Mes afectat',
'bud_sav_total_bank_eur' => 'Total al Banc (€)',
'bud_sav_ventilation' => 'Desglossament',
'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.',
'bud_sav_add_line' => 'Línia',
'bud_sav_current' => 'Actual',
'bud_sav_adjust' => 'Ajust (+/-)',
'bud_sav_new' => 'Nou',
'bud_sav_sum_mode_title' => 'Activar la suma ràpida',
'bud_sav_selection' => 'Selecció:',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_sav_saving' => 'Desant...',
'bud_err_tech' => 'S\'ha produït un error tècnic.',
'bud_sav_confirm_delete_month' => 'Suprimir TOT el mes de %m per a %o?',
'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:",
'bud_err_server' => 'Error del servidor: ',
'bud_err_network_dup' => 'Error de xarxa en duplicar.',
// --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Ingressos',
'bud_prev_person' => 'Persona',
'bud_prev_salary' => 'Sou',
'bud_prev_monthly_payment' => 'Mensualitat',
'bud_prev_joint_account' => 'Cte Comú',
'bud_prev_func_expenses' => 'Desp. Func.',
'bud_prev_perso_savings' => 'Est. Perso',
'bud_prev_family_savings' => 'Est. Família',
'bud_prev_available' => 'Dispo',
'bud_prev_perso_remaining' => 'Restant Perso',
'bud_prev_budget_alloc' => 'Distribució Pressupostària',
'bud_prev_today' => 'Avui',
'bud_prev_new_line' => 'Nova Línia',
'bud_prev_remaining' => 'Restant',
'bud_prev_confirm_del_line' => 'Suprimir aquesta línia i tot el seu historial?',
'bud_prev_notes_for' => 'Notes per a',
'bud_prev_saved' => 'Desat',
'bud_prev_notes_ph' => 'Escriu les teves observacions, recordatoris o comentaris per a aquest mes aquí...',
'bud_prev_save_note' => 'Desar la nota',
'bud_prev_transfers_to_make' => 'Transferències a fer',
'bud_prev_destination' => 'Destinació',
'bud_prev_done' => 'FET',
'bud_prev_validate' => 'Validar',
'bud_prev_grand_total' => 'GRAN TOTAL',
'bud_prev_new_line_title' => 'Nova Línia de Pressupost',
'bud_prev_edit_line_title' => 'Modificar la línia',
'bud_prev_label_target' => 'Objectiu (Destinació)',
'bud_prev_choose' => 'Tria',
'bud_prev_link_holiday' => 'Enllaçar a un viatge (Opcional)',
'bud_prev_no_link' => 'No associar',
'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.',
'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.",
'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.",
// ==========================================
// CADEAUX (GIFTS)
// ==========================================
'gift_page_title' => 'HouseHub - Llista de regals',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Nadal',
'gift_occ_rois' => 'Reis',
'gift_occ_anniv' => 'Aniversari',
'gift_occ_sant' => 'Sant',
'gift_main_title' => 'Llista de regals %s',
'gift_aria_change_view' => 'Canvia la vista',
'gift_view_nadal' => 'Nadal',
'gift_view_anniv' => 'Aniversaris',
'gift_view_by_party' => 'Vista per festa',
'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.',
'gift_add_gift' => 'Afegeix un regal',
'gift_paid_by' => 'pagat per %s',
'gift_summary_title' => 'Resum del pressupost',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Infant',
'gift_col_party' => 'Festa',
'gift_debtor' => 'Deutor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Cap deute pendent.',
'gift_owes' => 'ha de pagar',
'gift_to' => 'a',
'gift_detailed_list' => 'Llista detallada de regals',
'gift_col_gift' => 'Regal',
'gift_col_link' => 'Enllaç',
'gift_modal_title_add' => 'Afegeix un regal per a %s',
'gift_modal_title_edit' => 'Edita el regal',
'gift_modal_payer' => 'Pagat per',
'gift_modal_gift_name' => 'Nom del regal',
'gift_modal_ph_name' => 'p. ex., Lego Star Wars',
'gift_modal_price' => 'Preu (€)',
'gift_modal_link' => 'Enllaç (opcional)',
'gift_confirm_delete' => 'Vols eliminar aquest regal?',
'gift_filter_all_children' => 'Tots els nens',
'gift_filter_all_adults' => 'Tots els adults',
'gift_empty_state_no_gifts' => 'Cap regal de moment.',
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.',
// ==========================================
// GARAGE MANAGER
// ==========================================
'menu_garage' => 'Garatge',
'garage_page_title' => 'HouseHub - Garatge',
'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Manteniments',
'garage_stat_parts' => 'Peces',
'garage_stat_cost' => 'Cost total',
'garage_my_vehicles' => 'Els meus vehicles',
'garage_reminders' => 'Recordatoris i propers manteniments',
'garage_vehicles_title' => 'Els meus vehicles',
'garage_all_parts' => 'Totes les peces',
'garage_parts_count' => 'peces',
'garage_maintenances' => 'Manteniments',
'garage_parts' => 'Peces',
'garage_add' => 'Afegir',
'garage_add_vehicle' => 'Afegir un vehicle',
'garage_add_maintenance' => 'Afegir un manteniment',
'garage_add_part' => 'Afegir una peça',
'garage_vehicle_name' => 'Nom del vehicle',
'garage_brand' => 'Marca',
'garage_model' => 'Model',
'garage_year' => 'Any',
'garage_plate' => 'Matrícula',
'garage_fuel' => 'Combustible',
'garage_km' => 'Quilometratge actual',
'garage_color' => 'Color',
'garage_purchase_date' => 'Data de compra',
'garage_purchase_price' => 'Preu de compra (€)',
'garage_photo' => 'Foto',
'garage_notes' => 'Notes',
'garage_maint_type' => 'Tipus de manteniment',
'garage_date' => 'Data',
'garage_km_at' => 'Quilometratge en el moment',
'garage_labor_cost' => 'Cost mà d\'obra (€)',
'garage_description' => 'Descripció',
'garage_mechanic' => 'Mecànic',
'garage_garage' => 'Taller',
'garage_next_reminder' => 'Proper manteniment (recordatori)',
'garage_next_date' => 'Data prevista',
'garage_next_km' => 'Quilometratge previst',
'garage_part_name' => 'Nom de la peça',
'garage_category' => 'Categoria',
'garage_reference' => 'Referència',
'garage_unit_price' => 'Preu unitari (€)',
'garage_quantity' => 'Quantitat',
'garage_unit' => 'Unitat',
'garage_supplier' => 'Proveïdor',
'garage_change_photo' => 'Canviar la foto',
'garage_new_photo' => 'Nova foto',
'garage_update_photo' => 'Actualitzar',
];
+648
View File
@@ -0,0 +1,648 @@
<?php
// English Dictionary
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Running tests...',
'test_passed' => 'Test passed',
'test_failed' => 'Test failed',
'tests_title' => 'Test Lab',
'tests_run_budget' => 'Test Budget',
'tests_run_all' => 'Run all tests',
'tests_copy_report' => 'Copy report',
'tests_waiting' => 'Waiting to start...',
'tests_report_copied' => 'Report copied to clipboard!',
'tests_live_preview' => 'Live preview',
// ==========================================
// GLOBAL & NAVIGATION
// ==========================================
'menu_home' => 'Home',
'menu_calendar' => 'Calendar',
'menu_budget' => 'Budget',
'menu_holidays' => 'Trips',
'menu_gifts' => 'Gifts',
'menu_calendar_ios' => 'iOS Calendar',
'nav_home' => 'Home',
'nav_calendar' => 'Calendar',
'nav_budget' => 'Budget',
'nav_holidays' => 'Trips',
'nav_gifts' => 'Gifts',
'aria_menu' => 'Mobile menu',
// Buttons & global actions
'actions' => 'Actions',
'edit' => 'Edit',
'delete' => 'Delete',
'save' => 'Save',
'cancel' => 'Cancel',
'btn_save' => 'Save',
'btn_cancel' => 'Cancel',
'btn_delete' => 'Delete',
'btn_close' => 'Close',
'btn_back' => '◀ Back',
'btn_edit_bases' => '⚙️ Edit base values',
'btn_edit' => 'Edit',
'error_occured' => 'An error occurred.',
// Generic words & errors
'error' => 'Error.',
'confirm_delete' => 'Are you sure you want to delete this item?',
// Months & Days
'month_01' => 'January', 'month_02' => 'February', 'month_03' => 'March',
'month_04' => 'April', 'month_05' => 'May', 'month_06' => 'June',
'month_07' => 'July', 'month_08' => 'August', 'month_09' => 'September',
'month_10' => 'October', 'month_11' => 'November', 'month_12' => 'December',
'day_mon' => 'Mon', 'day_tue' => 'Tue', 'day_wed' => 'Wed',
'day_thu' => 'Thu', 'day_fri' => 'Fri', 'day_sat' => 'Sat', 'day_sun' => 'Sun',
// ==========================================
// AUTHENTICATION / LOGIN
// ==========================================
'login_title' => 'Login - HouseHub',
'login_header' => 'Login',
'label_username' => 'Username',
'label_password' => 'Password',
'placeholder_username' => 'e.g. john',
'btn_login' => 'Login',
'btn_login_submit' => 'Sign in',
'logout' => 'Logout',
'btn_logout' => 'Logout',
'error_missing_fields' => 'All fields are required.',
'error_invalid_credentials' => 'Invalid credentials.',
'login_subtitle' => 'Please sign in to continue',
// ==========================================
// HOME / INDEX
// ==========================================
'home_title' => 'HouseHub - Home',
'home_welcome' => 'Welcome to HouseHub',
'home_subtitle' => 'Family organisation control centre.',
'home_logged_as' => 'Logged in as',
'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Family Calendar',
'mod_calendar_desc' => 'Manage leave, childcare arrangements and weekly planning.',
'mod_holidays_name' => 'Trips',
'mod_holidays_desc' => 'Plan your next getaways, destinations and travel ideas.',
'mod_gifts_name' => 'Gifts',
'mod_gifts_desc' => 'Plan gifts for each child, for Christmas and birthdays.',
'mod_budget_name' => 'Budget',
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Groceries',
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
'menu_groceries' => 'Groceries',
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
'groceries_btn_add' => 'Add',
'groceries_btn_uncheck_all' => 'Reset cart checks',
'groceries_btn_delete_picked' => 'Remove checked items',
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
'groceries_added' => 'Added',
'groceries_updated' => 'Updated',
'groceries_deleted' => 'Removed',
'groceries_reset_next' => 'List ready for your next trip',
'groceries_picked_removed' => 'Checked items removed',
'groceries_label_empty' => 'Label cannot be empty',
'groceries_confirm_del_title' => 'Delete?',
'groceries_confirm_del_body' => 'Remove this product from the list?',
'groceries_confirm_uncheck_title' => 'Reset cart checks',
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
'groceries_confirm_clear_title' => 'Remove checked items',
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
'groceries_empty' => 'Nothing yet. Add a product above.',
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
'groceries_section_pending' => 'To pick up',
'groceries_section_cart' => 'In cart',
'groceries_prompt_edit' => 'Edit product',
'groceries_btn_clear_list' => 'Clear list',
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
'groceries_list_cleared' => 'List cleared',
'groceries_history_title' => 'History — click to add',
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
'groceries_history_meta' => 'Up to {max} products · ',
'groceries_settings_link' => 'Settings',
'groceries_settings_title' => 'Groceries — history',
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
'groceries_settings_history_max' => 'History size',
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
'groceries_settings_saved' => 'Grocery preferences saved.',
'groceries_settings_error' => 'Could not save:',
'groceries_err_duplicate' => 'This product is already on the list.',
'cta_open' => 'Open',
'cta_explore' => 'Explore',
'cta_view_lists' => 'Unwrap',
'cta_manage' => 'Manage',
'cta_service' => 'Service',
'cta_jot' => 'Jot down',
'cta_check' => 'Check off',
'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Print',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
'menu_planka' => 'Planka',
// ==========================================
// FAMILY CALENDAR
// ==========================================
'fc_page_title' => 'HouseHub - Calendar',
'fc_main_header' => 'Family Calendar',
'fc_btn_correct_balances' => 'Adjust balances',
'fc_btn_school_holidays' => 'School holidays',
'fc_modal_holidays_title' => 'School Holidays',
'fc_modal_snap_title' => 'Adjust a balance',
'fc_col_period' => 'Period',
'fc_col_from' => 'From',
'fc_col_to' => 'To',
'fc_label_person' => 'Person',
'fc_label_leave_type' => 'Leave type',
'fc_label_apply_date' => 'Application date',
'fc_snap_date_hint' => 'Preferably choose the 1st day of a month',
'fc_label_remaining_balance'=> 'Remaining balance at this date',
'fc_placeholder_balance' => 'e.g. 12.5',
'fc_btn_save_snap' => 'Save adjustment',
'fc_view_1m' => '1 month',
'fc_view_2m' => '2 months',
'fc_view_3m' => '3 months',
'fc_view_year' => 'Year',
'fc_weekly_planning' => 'Weekly planning',
'fc_col_month' => 'Month',
'fc_col_week_short' => 'Wk.',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Legend',
'fc_summary_title' => 'Summary',
'fc_summ_year' => 'Year',
'fc_summ_month' => 'Month',
'fc_school_year' => 'School year',
'fc_unit_days' => 'days',
'fc_menu_carole' => 'Carole\'s Leave',
'fc_clear' => 'Clear',
'fc_menu_kids_leaves' => 'Children\'s Leave',
'fc_err_save_snap' => 'Error saving adjustment.',
'fc_err_fetch_gov' => 'Error connecting to the government API.',
'fc_err_no_data_gov' => 'No data found on the government API for this year.',
'fc_err_action' => 'Error during action: ',
'fc_today_short' => 'Today',
'fc_today_title' => 'Today',
'leg_presence' => 'Pep presence',
'leg_school_holidays' => 'School holidays',
'leg_public_holiday' => 'Public holiday',
'leg_off_carole' => 'Carole off',
'leg_extra_off' => 'Extra off',
'leg_centre' => 'Centre',
'leg_avis' => 'Notice',
'leg_pep_sick' => 'Pep sick',
'vac_toussaint' => 'All Saints\' holidays',
'vac_noel' => 'Christmas holidays',
'vac_hiver' => 'Winter holidays',
'vac_printemps' => 'Spring holidays',
'vac_ascension' => 'Ascension bridge',
'vac_ete' => 'Summer holidays',
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risk of losing: %s d to take before %s',
'fc_alert_burn_jra' => '⚠️ JRA: Only 2 days can be carried over after February!',
// ==========================================
// HOLIDAYS (TRIPS)
// ==========================================
'hdl_title_list' => 'HouseHub - My Trips',
'hdl_title_detail' => 'HouseHub - Trip details',
'hdl_main_title' => 'My Trips',
'hdl_filter_all' => 'Show all',
'hdl_filter_year' => 'Year',
'hdl_left_to_pay_label' => 'Left to pay',
'hdl_btn_create' => 'Create a trip',
'hdl_no_active_trips' => 'No active trips for this period. Let\'s plan something!',
'hdl_history_title' => 'History',
'hdl_dates_to_define' => 'Dates to be defined',
'hdl_total_budget' => 'Total Budget',
'hdl_paid' => 'Paid',
'hdl_saved' => 'Funded',
'hdl_left_to_pay' => 'Left to pay',
'hdl_status_booked' => 'Confirmed',
'hdl_status_planned' => 'Planned',
'hdl_status_draft' => 'Draft',
'hdl_status_passed' => 'Completed',
'hdl_status_archived' => 'Archived',
'hdl_modal_title' => 'Plan the trip',
'hdl_label_name' => 'Trip name',
'hdl_ph_name' => 'e.g. October - Portugal',
'hdl_label_status' => 'Status',
'hdl_label_period' => 'Period (free text)',
'hdl_ph_period' => 'e.g. October 2026',
'hdl_label_from' => 'From',
'hdl_label_to' => 'To',
'hdl_cat_transport' => 'Transport',
'hdl_add_transport' => 'Add transport',
'hdl_cat_accommodation' => 'Accommodation',
'hdl_add_accommodation' => 'Add accommodation',
'hdl_cat_activity' => 'Activity',
'hdl_add_activity' => 'Add activity',
'hdl_label_budget_food' => 'Food & Drinks budget (€)',
'hdl_label_budget_extras' => 'Extras budget (€)',
'hdl_label_notes' => 'Notes',
'hdl_ph_notes' => 'Rough ideas...',
'hdl_label_budget_food_extras' => 'Food & Extras budget',
'hdl_map_itinerary' => 'Itinerary',
'hdl_outbound' => 'Outbound',
'hdl_return' => 'Return',
'hdl_btn_add_step' => 'Add a stop',
'hdl_step_details' => 'Road trip details',
'hdl_no_steps' => 'No stops defined yet. Start by adding one!',
'hdl_general_costs' => 'General costs & budgets',
'hdl_food_bev' => 'Food & Drinks',
'hdl_extras' => 'Extras',
'hdl_view_planning' => 'View planning',
'hdl_passing_point' => 'Passing point (no cost)',
'hdl_search_location' => 'Search a location',
'hdl_ph_search' => 'City, hotel, address...',
'hdl_label_step_name' => 'Stop name',
'hdl_label_arrival' => 'Arrival',
'hdl_label_departure' => 'Departure',
'hdl_planned_expenses' => 'Planned expenses for this stop',
'hdl_btn_add_expense' => '+ Add an expense',
'hdl_save_fav' => 'Save to favourites',
'hdl_planning_title' => 'Stop planning',
'hdl_to_pay' => 'To pay',
'hdl_js_search_loading' => 'Searching...',
'hdl_js_no_result' => 'No results found.',
'hdl_js_network_error' => 'Connection error to map service.',
'hdl_js_confirm_del_trip' => 'Are you sure you want to delete this entire trip?',
'hdl_js_confirm_del_step' => 'Delete this stop?',
'hdl_js_step_label' => 'Stop',
'hdl_js_ph_expense_name' => 'e.g. Hotel, Toll...',
'hdl_quick_edit_title' => 'Quick edit',
'hdl_js_edit_step' => 'Edit stop',
'hdl_js_missing_dates_title'=> 'Missing dates',
'hdl_js_missing_dates_msg' => 'Please set an arrival and departure date to organise the planning.',
'hdl_to_place' => 'To place',
'hdl_from' => 'From',
'hdl_to' => 'To',
'weather_forecast' => 'Weather forecast',
'weather_not_available' => 'Weather unavailable',
'temp_max' => 'Max temp.',
'weather_loading' => 'Loading weather...',
'weather_error' => 'Weather unavailable',
'weather_sunny' => 'Sunny',
'weather_cloudy' => 'Cloudy',
'weather_rainy' => 'Rainy',
'weather_snowy' => 'Snowy',
'weather_historical' => 'Based on historical data (estimate)',
'err_invalid_holiday_id' => 'Invalid trip ID.',
'hdl_default_exp_name' => 'Expense',
'hdl_select_beneficiary' => '-- Select --',
// Legacy holiday keys (kept for compatibility)
'outbound_trip' => 'Outbound',
'return_trip' => 'Return',
'period' => 'Period',
'budget_food' => 'Food & Extras budget',
'total_cost' => 'Total Estimated Cost',
'paid' => 'Paid',
'provisioned' => 'Provisioned',
'left_to_pay' => 'Left to pay',
'map_itinerary' => '🗺️ Itinerary',
'place_step' => '📍 Place a stop',
'step_details' => '📝 Stop details',
'no_steps' => 'No stops planned.',
'travel_notes' => '📝 Trip notes',
'to_be_defined' => 'To be defined',
'funded' => 'Funded (Provision)',
'view_planning' => 'View planning',
'to_pay' => 'To pay',
'from_date' => 'From',
'to_date' => 'to',
'passing_point' => 'Passing point (no expense)',
'search_location' => 'Search a geographical location',
'search_placeholder' => 'e.g. Paris, Hotel Barcelona...',
'step_name' => 'Stop name',
'arrival_optional' => 'Arrival (Optional)',
'departure_optional' => 'Departure (Optional)',
'planned_expenses' => 'Planned expenses',
'add_expense' => '+ Add an expense',
'save_favorite' => 'Save to favourites',
'planning_title' => 'Planning',
'modal_plan_trip' => 'Plan the trip',
'modal_quick_edit' => 'Quick edit',
'err_modal_missing' => 'Error: holidayModal not found.',
'placeholder_title' => 'Title',
'placeholder_price' => 'Price (€)',
'already_paid' => 'Already paid?',
'remove_line' => 'Remove this line',
'confirm_delete_trip' => 'Are you sure you want to permanently delete this trip? This action cannot be undone.',
'step_number' => 'Stop',
'place_new_step' => '📍 Place a new stop',
'edit_step' => '✏️ Edit stop',
'search_in_progress' => 'Searching... ⏳',
'no_result_found' => 'No results found.',
'network_error' => 'Network error.',
'placeholder_label' => 'Label',
'placeholder_notes_link' => '🔗 Booking link or notes (Optional)...',
'confirm_delete_step' => 'Delete this stop and all its associated expenses?',
'planning_of' => '📅 Planning: ',
'missing_dates_title' => '⚠️ Missing dates',
'missing_dates_msg' => 'Please edit this stop (✏️) and fill in the <strong>arrival and departure dates</strong>.',
'to_place' => '📥 To place',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// ==========================================
'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Budget Management',
'budget_tab_tracking' => 'Monthly tracking',
'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Savings',
'budget_tab_recap' => 'Summary',
'budget_err_file_not_found' => 'Oops! File not found.',
'budget_err_file_detail' => 'Check that the file views/%s.php exists.',
'budget_err_not_authorized' => 'Page not authorised.',
'cat_income' => 'Income',
'cat_fmcg' => 'Groceries (FMCG)',
'cat_fuel' => 'Fuel',
'cat_school' => 'School / Childcare',
'cat_fixed' => 'Fixed costs',
'cat_others' => 'Other / Unexpected',
'cat_savings' => 'Savings',
// --- BUDGET: TRACKING ---
'bud_rem_school' => 'School (estimated remaining)',
'bud_rem_fmcg' => 'Groceries (estimated remaining)',
'bud_rem_fuel' => 'Fuel (estimated remaining)',
'bud_closed_archived' => 'This month is closed and archived.',
'bud_budget_of' => 'Budget:',
'bud_confirm_close' => 'Close this month? This will freeze the current balances and switch the default tracking to next month.',
'bud_close_btn' => 'Close',
'bud_confirm_reopen' => 'Reopen this month? Its balances will be recalculated live.',
'bud_reopen_btn' => 'Reopen',
'bud_active_month_btn' => 'Active month',
'bud_bank_balance' => 'Bank balance',
'bud_frozen' => 'Frozen',
'bud_current' => 'Current',
'bud_update_btn' => 'Upd.',
'bud_theoretical_balance' => 'Theoretical end-of-month balance',
'bud_upcoming_charges' => 'Upcoming charges',
'bud_all_paid' => 'All paid!',
'bud_capacity_tooltip' => 'Calculation: Previous month carry-over (%s€) + Salaries (%s€) + Other income (%s€)',
'bud_max_capacity' => 'Max capacity',
'bud_actual' => 'Actual',
'bud_theoretical' => 'Theoretical',
'bud_import_csv' => 'Import CSV',
'bud_csv_file' => 'CSV file (semicolon-separated)',
'bud_preview' => 'Preview',
'bud_validate_import' => 'Validate import',
'bud_assign_month' => 'Assign to month',
'bud_op_date' => 'Operation date',
'bud_label' => 'Label',
'bud_amount' => 'Amount',
'bud_category' => 'Category',
'bud_ignore' => 'Ignore',
'bud_to_define' => 'To define',
'bud_is_charge' => 'Fixed charge?',
'bud_is_income' => 'Income?',
'btn_import' => 'Import',
'bud_no_lines' => 'No lines.',
'bud_confirm_delete' => 'Delete this line?',
'bud_new_transaction' => 'New transaction',
'bud_assign_to_month' => 'Assign to management month',
'bud_real_date' => 'Actual operation date',
'bud_type' => 'Type',
'bud_type_expense' => 'Expense',
'bud_type_income' => 'Income / Reimbursement',
'bud_beneficiary' => 'Beneficiary',
'bud_fixed_charge' => 'Fixed charge',
'bud_expected_income' => 'Expected income',
'bud_amount_eur' => 'Amount (€)',
'bud_update_balance' => 'Update bank balance',
'bud_balance_eur' => 'Balance (€)',
'bud_add_title' => 'Add',
'bud_edit_title' => 'Edit transaction',
'bud_to_define_js' => 'to define',
'bud_bar_actual' => 'Actual',
'bud_bar_theoretical' => 'Theoretical',
'bud_bar_max_cap' => 'Max capacity',
// --- BUDGET: RECAP ---
'bud_recap_monthly_title' => 'Monthly summary',
'bud_recap_btn_add' => 'Add a cost / income',
'bud_planned_amount' => 'Planned amount',
'bud_day' => 'Day',
'bud_month_state' => 'Month status',
'bud_est_short' => 'Est.',
'bud_reg_planned_in' => 'Regularisation planned in %s',
'bud_bonus' => 'Bonus',
'bud_overrun' => 'Overrun',
'bud_remaining' => 'Remaining',
'bud_exact_amount' => 'Exact amount',
'bud_per_month_short' => 'i.e.',
'bud_month_short' => 'month',
'bud_freq_mensuel' => 'Monthly',
'bud_freq_annuel' => 'Annual',
'bud_state_validated' => 'Validated',
'bud_state_partial' => 'Partial',
'bud_state_waiting' => 'Pending',
'bud_total_income_smoothed' => 'Total income (smoothed)',
'bud_total_expenses_smoothed'=> 'Total expenses (smoothed)',
'bud_theoretical_balance_recap'=> 'Theoretical balance',
'bud_recap_footer_note' => 'Payment status updates automatically based on operations imported in the "Monthly tracking" tab.',
'bud_recap_modal_add' => 'Add an item',
'bud_recap_modal_edit' => 'Edit',
'bud_label_name' => 'Name',
'bud_ph_item_name' => 'e.g. Rent, Salary...',
'bud_label_keywords' => 'Keywords (auto-detection)',
'bud_ph_keywords' => 'e.g. NETFLIX, PRIME VIDEO (comma-separated)',
'bud_help_keywords' => 'If a monthly expense contains this word, the line will switch to "Validated".',
'bud_label_day' => 'Day (1-31)',
'bud_cat_income' => 'Savings',
'bud_cat_expense' => 'Expense (Cost)',
'bud_label_frequency' => 'Frequency',
'bud_label_amount_type' => 'Amount type',
'bud_type_fixed' => 'Fixed (Invoice)',
'bud_type_variable' => 'Variable (Estimate)',
'bud_label_regularization' => 'Regularisation',
'bud_reg_none' => 'None',
'bud_recap_confirm_delete' => 'Are you sure you want to delete this item?',
'bud_err_delete' => 'Error while deleting.',
// --- BUDGET: SAVINGS ---
'bud_sav_add_month' => 'Enter a month',
'bud_sav_add_one_month' => '+1 Month',
'bud_sav_no_data' => 'No data for %s.',
'bud_sav_post_month' => 'Category / Month',
'bud_sav_from_date' => 'From %s',
'bud_sav_edit_modal' => 'Edit with modal',
'bud_sav_delete_month' => 'Delete entire month',
'bud_sav_total_bank' => 'Total bank',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Enter a month',
'bud_sav_modal_title_edit' => 'Edit:',
'bud_sav_month_concerned' => 'Month concerned',
'bud_sav_total_bank_eur' => 'Total in bank (€)',
'bud_sav_ventilation' => 'Breakdown',
'bud_sav_adj_help' => 'Use the adjustment (+/-) to recalculate automatically.',
'bud_sav_add_line' => 'Line',
'bud_sav_current' => 'Current',
'bud_sav_adjust' => 'Adj. (+/-)',
'bud_sav_new' => 'New',
'bud_sav_sum_mode_title' => 'Enable quick addition',
'bud_sav_selection' => 'Selection:',
'bud_sav_ph_name' => 'Name (e.g. Holidays)',
'bud_sav_saving' => 'Saving...',
'bud_err_tech' => 'A technical error occurred.',
'bud_sav_confirm_delete_month' => 'Delete ALL of month %m for %o?',
'bud_sav_prompt_duplicate' => "Do you want to copy data from %s to %t1?\n\nEnter the new TOTAL bank balance (€) for %t2:",
'bud_err_server' => 'Server error: ',
'bud_err_network_dup' => 'Network error during duplication.',
// --- BUDGET: FORECAST ---
'bud_prev_incomes' => 'Income',
'bud_prev_person' => 'Person',
'bud_prev_salary' => 'Salary',
'bud_prev_monthly_payment' => 'Monthly payment',
'bud_prev_joint_account' => 'Joint account',
'bud_prev_func_expenses' => 'Running costs',
'bud_prev_perso_savings' => 'Personal savings',
'bud_prev_family_savings' => 'Family savings',
'bud_prev_available' => 'Available',
'bud_prev_perso_remaining' => 'Personal remaining',
'bud_prev_budget_alloc' => 'Budget allocation',
'bud_prev_today' => 'Today',
'bud_prev_new_line' => 'New line',
'bud_prev_remaining' => 'Remaining',
'bud_prev_confirm_del_line' => 'Delete this line and all its history?',
'bud_prev_notes_for' => 'Notes for',
'bud_prev_saved' => 'Saved',
'bud_prev_notes_ph' => 'Write your remarks, reminders or comments for this month here...',
'bud_prev_save_note' => 'Save note',
'bud_prev_transfers_to_make' => 'Transfers to make',
'bud_prev_destination' => 'Destination',
'bud_prev_done' => 'DONE',
'bud_prev_validate' => 'Validate',
'bud_prev_grand_total' => 'GRAND TOTAL',
'bud_prev_new_line_title' => 'New budget line',
'bud_prev_edit_line_title' => 'Edit line',
'bud_prev_label_target' => 'Target (Destination)',
'bud_prev_choose' => 'Choose',
'bud_prev_link_holiday' => 'Link to a trip (Optional)',
'bud_prev_no_link' => 'Do not associate',
'bud_prev_err_no_history' => 'Cannot copy: no history available.',
'bud_prev_confirm_copy' => "Do you want to copy data from %s to %t?\n\n⚠️ This will overwrite all values already present for %t.",
'bud_prev_confirm_transfers' => "Confirm that %p has made all their transfers for %m?\n\nThis will automatically update Savings.",
'bud_prev_label_name' => 'Name',
// ==========================================
// GIFTS
// ==========================================
'gift_page_title' => 'HouseHub - Gift list',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Christmas',
'gift_occ_rois' => 'Three Kings',
'gift_occ_anniv' => 'Birthday',
'gift_occ_sant' => 'Name day',
'gift_main_title' => 'Gift list %s',
'gift_aria_change_view' => 'Change view',
'gift_view_nadal' => 'Christmas',
'gift_view_anniv' => 'Birthdays',
'gift_view_by_party' => 'View by occasion',
'gift_no_gifts' => 'No gifts recorded for %s in this view.',
'gift_add_gift' => 'Add a gift',
'gift_paid_by' => 'paid by %s',
'gift_summary_title' => 'Budget summary',
'gift_col_adult' => 'Adult',
'gift_col_child' => 'Child',
'gift_col_party' => 'Occasion',
'gift_debtor' => 'Debtor',
'gift_creditor' => 'Creditor',
'gift_liquidations' => 'Settlement',
'gift_no_debt' => 'No outstanding debts.',
'gift_owes' => 'owes',
'gift_to' => 'to',
'gift_detailed_list' => 'Detailed gift list',
'gift_col_gift' => 'Gift',
'gift_col_link' => 'Link',
'gift_modal_title_add' => 'Add a gift for %s',
'gift_modal_title_edit' => 'Edit gift',
'gift_modal_payer' => 'Paid by',
'gift_modal_gift_name' => 'Gift name',
'gift_modal_ph_name' => 'e.g. Lego Star Wars',
'gift_modal_price' => 'Price (€)',
'gift_modal_link' => 'Link (optional)',
'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
'gift_filter_all_children' => 'All children',
'gift_filter_all_adults' => 'All adults',
'gift_empty_state_no_gifts' => 'No gifts yet.',
'gift_empty_state_no_filter' => 'No gifts match the filter.',
'gift_view_matrix' => 'View detailed matrix',
// ==========================================
// GARAGE MANAGER
// ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Vehicles',
'garage_stat_maintenances' => 'Services',
'garage_stat_parts' => 'Parts',
'garage_stat_cost' => 'Total cost',
'garage_my_vehicles' => 'My vehicles',
'garage_reminders' => 'Reminders & upcoming services',
'garage_vehicles_title' => 'My vehicles',
'garage_all_parts' => 'All parts',
'garage_parts_count' => 'parts',
'garage_maintenances' => 'Services',
'garage_parts' => 'Parts',
'garage_add' => 'Add',
'garage_add_vehicle' => 'Add a vehicle',
'garage_add_maintenance' => 'Add a service',
'garage_add_part' => 'Add a part',
'garage_vehicle_name' => 'Vehicle name',
'garage_brand' => 'Make',
'garage_model' => 'Model',
'garage_year' => 'Year',
'garage_plate' => 'Licence plate',
'garage_fuel' => 'Fuel type',
'garage_km' => 'Current mileage',
'garage_color' => 'Colour',
'garage_purchase_date' => 'Purchase date',
'garage_purchase_price' => 'Purchase price (€)',
'garage_photo' => 'Photo',
'garage_notes' => 'Notes',
'garage_maint_type' => 'Service type',
'garage_date' => 'Date',
'garage_km_at' => 'Mileage at the time',
'garage_labor_cost' => 'Labour cost (€)',
'garage_description' => 'Description',
'garage_mechanic' => 'Mechanic',
'garage_garage' => 'Garage / Workshop',
'garage_next_reminder' => 'Next service (reminder)',
'garage_next_date' => 'Planned date',
'garage_next_km' => 'Planned mileage',
'garage_part_name' => 'Part name',
'garage_category' => 'Category',
'garage_reference' => 'Reference',
'garage_unit_price' => 'Unit price (€)',
'garage_quantity' => 'Quantity',
'garage_unit' => 'Unit',
'garage_supplier' => 'Supplier',
'garage_change_photo' => 'Change photo',
'garage_new_photo' => 'New photo',
'garage_update_photo' => 'Update',
];
+662
View File
@@ -0,0 +1,662 @@
<?php
// Dictionnaire Français
return [
// ==========================================
// TEST AUTO
// ==========================================
'test_running' => 'Tests en cours...',
'test_passed' => 'Test réussi',
'test_failed' => 'Test échoué',
'tests_title' => 'Laboratoire de Tests',
'tests_run_budget' => 'Tester le Budget',
'tests_run_all' => 'Lancer tous les tests',
'tests_copy_report' => 'Copier le rapport',
'tests_waiting' => 'En attente du lancement...',
'tests_report_copied' => 'Rapport copié dans le presse-papier !',
'tests_live_preview' => 'Aperçu en direct',
// ==========================================
// GLOBAL & NAVIGATION
// ==========================================
'menu_home' => 'Accueil',
'menu_calendar' => 'Calendrier',
'menu_budget' => 'Budget',
'menu_holidays' => 'Voyages',
'menu_gifts' => 'Cadeaux',
'menu_calendar_ios' => 'Calendrier iOS',
'nav_home' => 'Accueil',
'nav_calendar' => 'Calendrier',
'nav_budget' => 'Budget',
'nav_holidays' => 'Voyages',
'nav_gifts' => 'Cadeaux',
'aria_menu' => 'Menu mobile',
// Boutons et actions globales
'actions' => 'Actions',
'edit' => 'Modifier',
'delete' => 'Supprimer',
'save' => 'Enregistrer',
'cancel' => 'Annuler',
'btn_save' => 'Enregistrer',
'btn_cancel' => 'Annuler',
'btn_delete' => 'Supprimer',
'btn_close' => 'Fermer',
'btn_back' => '◀ Retour',
'btn_edit_bases' => '⚙️ Modifier les bases',
'btn_edit' => 'Modifier',
'error_occured' => 'Une erreur est survenue.',
// Mots génériques & Erreurs
'error' => 'Erreur.',
'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?',
// Mois & Jours
'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars',
'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin',
'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre',
'month_10' => 'Octobre', 'month_11' => 'Novembre','month_12' => 'Décembre',
'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer',
'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim',
// ==========================================
// AUTHENTIFICATION / LOGIN
// ==========================================
'login_title' => 'Connexion - HouseHub',
'login_header' => 'Connexion',
'label_username' => 'Identifiant',
'label_password' => 'Mot de passe',
'placeholder_username' => 'ex: pacha',
'btn_login' => 'Connexion',
'btn_login_submit' => 'Se connecter',
'logout' => 'Déconnexion',
'btn_logout' => 'Déconnexion',
'error_missing_fields' => 'Champs obligatoires.',
'error_invalid_credentials' => 'Identifiants incorrects.',
'login_subtitle' => 'Veuillez vous identifier pour continuer',
// ==========================================
// ACCUEIL / INDEX
// ==========================================
'home_title' => 'HouseHub - Accueil',
'home_welcome' => 'Bienvenue sur HouseHub',
'home_subtitle' => "Centre de contrôle de l'organisation familiale.",
'home_logged_as' => 'Connecté en tant que',
'home_modules_title' => 'Modules',
'mod_calendar_name' => 'Calendrier Familial',
'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.",
'mod_holidays_name' => 'Vacances',
'mod_holidays_desc' => 'Planifiez vos prochaines escapades, destinations et idées de voyage.',
'mod_gifts_name' => 'Cadeaux',
'mod_gifts_desc' => 'Planifiez les cadeaux pour chaque enfant, pour Noël et les anniversaires.',
'mod_budget_name' => 'Budget',
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
'menu_memo' => 'Notes',
'mod_todo_name' => 'Todo',
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'menu_groceries' => 'Courses',
// Module Liste (remplace Courses)
'mod_liste_name' => 'Listes',
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
'menu_liste' => 'Listes',
'liste_placeholder_add' => 'Ajouter un article…',
'liste_btn_add' => 'Ajouter',
'liste_btn_uncheck_all' => 'Tout décocher',
'liste_btn_delete_picked' => 'Supprimer les cochés',
'liste_btn_clear_all' => 'Vider la liste',
'liste_subtitle' => '{pending} à faire · {cart} cochés',
'liste_added' => 'Ajouté',
'liste_updated' => 'Modifié',
'liste_deleted' => 'Supprimé',
'liste_already_in' => 'Déjà dans la liste',
'liste_confirm_clear' => 'Vider toute la liste ?',
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'liste_new_list' => 'Nouvelle liste',
'liste_list_name_placeholder'=> 'Nom de la liste…',
'liste_create' => 'Créer',
'liste_rename_list' => 'Renommer',
'liste_new_name' => 'Nouveau nom :',
'liste_list_deleted' => 'Liste supprimée',
'liste_list_created' => 'Liste créée',
'liste_list_renamed' => 'Liste renommée',
'liste_edit_placeholder' => 'Modifier l\'article :',
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'liste_settings_title' => 'Listes — historique',
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'liste_settings_history_max' => 'Taille de l\'historique',
'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_error' => 'Impossible d\'enregistrer :',
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer',
'cta_view_lists' => 'Déballer',
'cta_manage' => 'Gérer',
'cta_service' => 'Réviser',
'cta_jot' => 'Griffonner',
'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimer',
'mod_planka_name' => 'Planka',
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
'menu_planka' => 'Planka',
// ==========================================
// FAMILY CALENDAR
// ==========================================
'fc_page_title' => 'HouseHub - Calendrier',
'fc_main_header' => 'Calendrier Familial',
'fc_btn_correct_balances' => 'Corriger les soldes',
'fc_btn_school_holidays' => 'Vacances scolaires',
'fc_modal_holidays_title' => 'Vacances Scolaires',
'fc_modal_snap_title' => 'Ajuster un solde',
'fc_col_period' => 'Période',
'fc_col_from' => 'Du',
'fc_col_to' => 'Au',
'fc_label_person' => 'Personne',
'fc_label_leave_type' => 'Type de congé',
'fc_label_apply_date' => "Date d'application",
'fc_snap_date_hint' => "Choisissez de préférence le 1er jour d'un mois",
'fc_label_remaining_balance'=> 'Solde restant à cette date',
'fc_placeholder_balance' => 'ex: 12.5',
'fc_btn_save_snap' => 'Enregistrer le correctif',
'fc_view_1m' => '1 mois',
'fc_view_2m' => '2 mois',
'fc_view_3m' => '3 mois',
'fc_view_year' => 'Année',
'fc_weekly_planning' => 'Planning hebdomadaire',
'fc_col_month' => 'Mois',
'fc_col_week_short' => 'Sem.',
'fc_col_av' => 'Av.',
'fc_col_use' => 'Use',
'fc_legend_title' => 'Légende',
'fc_summary_title' => 'Récapitulatif',
'fc_summ_year' => 'Année',
'fc_summ_month' => 'Mois',
'fc_school_year' => 'Année scolaire',
'fc_unit_days' => 'jours',
'fc_menu_carole' => 'Congés Carole',
'fc_clear' => 'Effacer',
'fc_menu_kids_leaves' => 'Congés Enfants',
'fc_err_save_snap' => 'Erreur lors de la sauvegarde du correctif.',
'fc_err_fetch_gov' => 'Erreur lors de la connexion à l\'API gouvernementale.',
'fc_err_no_data_gov' => 'Aucune donnée trouvée sur l\'API du gouvernement pour cette année.',
'fc_err_action' => 'Erreur lors de l\'action : ',
'fc_today_short' => 'Auj.',
'fc_today_title' => "Aujourd'hui",
'leg_presence' => 'Présence Pep',
'leg_school_holidays' => 'Vacances Scolaires',
'leg_public_holiday' => 'Férié',
'leg_off_carole' => 'Off Carole',
'leg_extra_off' => 'Extra Off',
'leg_centre' => 'Centre',
'leg_avis' => 'Avis',
'leg_pep_sick' => 'Pep Malade',
'vac_toussaint' => 'Vacances de la Toussaint',
'vac_noel' => 'Vacances de Noël',
'vac_hiver' => "Vacances d'Hiver",
'vac_printemps' => 'Vacances de Printemps',
'vac_ascension' => 'Pont de l\'Ascension',
'vac_ete' => "Vacances d'Été",
'btn_off' => 'Off',
'btn_extra' => 'Extra',
'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant le %s',
'fc_alert_burn_jra' => '⚠️ JRA : Seuls 2j sont reportables après février !',
// ==========================================
// HOLIDAYS (VOYAGES)
// ==========================================
'hdl_title_list' => 'HouseHub - Mes Vacances',
'hdl_title_detail' => 'HouseHub - Détail du voyage',
'hdl_main_title' => 'Mes Vacances',
'hdl_filter_all' => 'Tout afficher',
'hdl_filter_year' => 'Année',
'hdl_left_to_pay_label' => 'Reste à payer',
'hdl_btn_create' => 'Créer un voyage',
'hdl_no_active_trips' => 'Aucun voyage en cours pour cette période. Planifions quelque chose !',
'hdl_history_title' => 'Historique',
'hdl_dates_to_define' => 'Dates à définir',
'hdl_total_budget' => 'Budget Total',
'hdl_paid' => 'Payé',
'hdl_saved' => 'Financé',
'hdl_left_to_pay' => 'Reste à payer',
'hdl_status_booked' => 'Confirmé',
'hdl_status_planned' => 'Planifié',
'hdl_status_draft' => 'Brouillon',
'hdl_status_passed' => 'Terminé',
'hdl_status_archived' => 'Archivé',
'hdl_modal_title' => 'Planifier le voyage',
'hdl_label_name' => 'Nom du voyage',
'hdl_ph_name' => 'Ex: Octobre - Portugal',
'hdl_label_status' => 'Statut',
'hdl_label_period' => 'Période (Texte libre)',
'hdl_ph_period' => 'Ex: Octobre 2026',
'hdl_label_from' => 'Du',
'hdl_label_to' => 'Au',
'hdl_cat_transport' => 'Transport',
'hdl_add_transport' => 'Ajouter un transport',
'hdl_cat_accommodation' => 'Hébergement',
'hdl_add_accommodation' => 'Ajouter un hébergement',
'hdl_cat_activity' => 'Activité',
'hdl_add_activity' => 'Ajouter une activité',
'hdl_label_budget_food' => 'Budget Food & Bev (€)',
'hdl_label_budget_extras' => 'Budget Extras (€)',
'hdl_label_notes' => 'Notes',
'hdl_ph_notes' => 'Idées en vrac...',
'hdl_label_budget_food_extras' => 'Budget Food & Extras',
'hdl_map_itinerary' => 'Itinéraire',
'hdl_outbound' => 'Aller',
'hdl_return' => 'Retour',
'hdl_btn_add_step' => 'Ajouter une étape',
'hdl_step_details' => 'Détails du roadtrip',
'hdl_no_steps' => 'Aucune étape définie. Commencez par en ajouter une !',
'hdl_general_costs' => 'Frais généraux & Budgets',
'hdl_food_bev' => 'Food & Bev',
'hdl_extras' => 'Extras',
'hdl_view_planning' => 'Voir le planning',
'hdl_passing_point' => 'Point de passage (sans frais)',
'hdl_search_location' => 'Chercher un lieu',
'hdl_ph_search' => 'Ville, hôtel, adresse...',
'hdl_label_step_name' => "Nom de l'étape",
'hdl_label_arrival' => 'Arrivée',
'hdl_label_departure' => 'Départ',
'hdl_planned_expenses' => 'Dépenses prévues pour cette étape',
'hdl_btn_add_expense' => '+ Ajouter une dépense',
'hdl_save_fav' => 'Enregistrer dans mes favoris',
'hdl_planning_title' => 'Planning de l\'étape',
'hdl_to_pay' => 'À payer',
'hdl_js_search_loading' => 'Recherche en cours...',
'hdl_js_no_result' => 'Aucun résultat trouvé.',
'hdl_js_network_error' => 'Erreur de connexion au service de carte.',
'hdl_js_confirm_del_trip' => 'Es-tu sûr de vouloir supprimer ce voyage entier ?',
'hdl_js_confirm_del_step' => 'Supprimer cette étape ?',
'hdl_js_step_label' => 'Étape',
'hdl_js_ph_expense_name' => 'Ex: Hôtel, Péage...',
'hdl_quick_edit_title' => 'Modification rapide',
'hdl_js_edit_step' => 'Modifier l\'étape',
'hdl_js_missing_dates_title'=> 'Dates manquantes',
'hdl_js_missing_dates_msg' => 'Veuillez définir une date d\'arrivée et de départ pour organiser le planning.',
'hdl_to_place' => 'À placer',
'hdl_from' => 'Du',
'hdl_to' => 'Au',
'hdl_btn_add_step' => 'Ajouter une étape',
'hdl_quick_edit_title' => 'Modification rapide',
'hdl_paid' => 'Payé',
'weather_forecast' => 'Prévisions météo',
'weather_not_available' => 'Météo indisponible',
'temp_max' => 'Temp. Max',
'weather_loading' => 'Chargement météo...',
'weather_error' => 'Météo indisponible',
'weather_sunny' => 'Ensoleillé',
'weather_cloudy' => 'Nuageux',
'weather_rainy' => 'Pluvieux',
'weather_snowy' => 'Neigeux',
'weather_historical' => 'Basé sur l\'historique (estimation)',
'err_invalid_holiday_id' => 'ID de voyage invalide.',
'hdl_default_exp_name' => 'Dépense',
'hdl_select_beneficiary' => '-- Sélectionner --',
// Anciennes clés Holidays non préfixées (conservées pour compatibilité si utilisées)
'outbound_trip' => 'Aller',
'return_trip' => 'Retour',
'period' => 'Période',
'budget_food' => 'Budget Food & Extras',
'total_cost' => 'Coût Total Estimé',
'paid' => 'Payé',
'provisioned' => 'Provisionné',
'left_to_pay' => 'Reste à payer',
'map_itinerary' => '🗺️ Itinéraire',
'place_step' => '📍 Placer une étape',
'step_details' => '📝 Détail des étapes',
'no_steps' => 'Aucune étape planifiée.',
'travel_notes' => '📝 Notes du voyage',
'to_be_defined' => 'À définir',
'funded' => 'Financé (Provision)',
'view_planning' => 'Voir le planning',
'to_pay' => 'À payer',
'from_date' => 'Du',
'to_date' => 'au',
'passing_point' => 'Point de passage (Aucune dépense)',
'search_location' => 'Rechercher un lieu géographique',
'search_placeholder' => 'Ex: Paris, Ibis Barcelone...',
'step_name' => 'Nom de l\'étape',
'arrival_optional' => 'Arrivée le (Optionnel)',
'departure_optional' => 'Départ le (Optionnel)',
'planned_expenses' => 'Dépenses prévues',
'add_expense' => '+ Ajouter une dépense',
'save_favorite' => 'Sauvegarder dans mes favoris',
'planning_title' => 'Planning',
'modal_plan_trip' => 'Planifier le voyage',
'modal_quick_edit' => 'Modification rapide',
'err_modal_missing' => 'Erreur : La modale holidayModal est introuvable.',
'placeholder_title' => 'Intitulé',
'placeholder_price' => 'Prix (€)',
'already_paid' => 'Déjà payé ?',
'remove_line' => 'Retirer cette ligne',
'confirm_delete_trip' => 'Voulez-vous vraiment supprimer définitivement ce voyage ? Cette action est irréversible.',
'step_number' => 'Étape',
'place_new_step' => '📍 Placer une nouvelle étape',
'edit_step' => '✏️ Modifier l\'étape',
'search_in_progress' => 'Recherche en cours... ⏳',
'no_result_found' => 'Aucun résultat trouvé.',
'network_error' => 'Erreur réseau.',
'placeholder_label' => 'Libellé',
'placeholder_notes_link' => '🔗 Lien de réservation ou notes (Optionnel)...',
'confirm_delete_step' => 'Supprimer cette étape et toutes les dépenses associées ?',
'planning_of' => '📅 Planning : ',
'missing_dates_title' => '⚠️ Dates manquantes',
'missing_dates_msg' => 'Veuillez modifier cette étape (✏️) et renseigner sa <strong>Date d\'arrivée et de départ</strong>.',
'to_place' => '📥 À Placer',
// ==========================================
// BUDGET (GLOBAL & CATEGORIES)
// ==========================================
'budget_page_title' => 'HouseHub - Budget',
'budget_main_header' => 'Gestion du Budget',
'budget_tab_tracking' => 'Suivi Mensuel',
'budget_tab_prev' => 'Budget 2026',
'budget_tab_savings' => 'Épargne',
'budget_tab_recap' => 'Récapitulatif',
'budget_err_file_not_found' => 'Oups ! Fichier introuvable.',
'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.',
'budget_err_not_authorized' => 'Page non autorisée.',
'cat_income' => 'Revenus',
'cat_fmcg' => 'Courses (FMCG)',
'cat_fuel' => 'Essence',
'cat_school' => 'École / Garde',
'cat_fixed' => 'Charges Fixes',
'cat_others' => 'Autres / Imprévus',
'cat_savings' => 'Épargne',
// --- BUDGET : SUIVI ---
'bud_rem_school' => 'École (Reste estimé)',
'bud_rem_fmcg' => 'Courses (Reste estimé)',
'bud_rem_fuel' => 'Essence (Reste estimé)',
'bud_closed_archived' => 'Ce mois est clôturé et archivé.',
'bud_budget_of' => 'Budget :',
'bud_confirm_close' => 'Clôturer ce mois ? Cela figera les soldes actuels et basculera le suivi par défaut sur le mois prochain.',
'bud_close_btn' => 'Clôturer',
'bud_confirm_reopen' => 'Rouvrir ce mois ? Ses soldes seront recalculés en direct.',
'bud_reopen_btn' => 'Rouvrir',
'bud_active_month_btn' => 'Mois Actif',
'bud_bank_balance' => 'Solde bancaire',
'bud_frozen' => 'Figé',
'bud_current' => 'Actuel',
'bud_update_btn' => 'MàJ',
'bud_theoretical_balance' => 'Solde théorique fin de mois',
'bud_upcoming_charges' => 'Charges à venir',
'bud_all_paid' => 'Tout est payé !',
'bud_capacity_tooltip' => 'Calcul : Report Mois Précédent (%s€) + Salaires (%s€) + Autres rentrées (%s€)',
'bud_max_capacity' => 'Capacité Max',
'bud_actual' => 'Actuel',
'bud_theoretical' => 'Théorique',
'bud_import_csv' => 'Importer CSV',
'bud_csv_file' => 'Fichier CSV (Point-virgule)',
'bud_preview' => 'Prévisualiser',
'bud_validate_import' => 'Valider l\'importation',
'bud_assign_month' => 'Imputer au mois',
'bud_op_date' => 'Date Opération',
'bud_label' => 'Libellé',
'bud_amount' => 'Montant',
'bud_category' => 'Catégorie',
'bud_ignore' => 'Ignorer',
'bud_to_define' => 'À définir',
'bud_is_charge' => 'Charge ?',
'bud_is_income' => 'Revenu ?',
'btn_import' => 'Importer',
'bud_no_lines' => 'Aucune ligne.',
'bud_confirm_delete' => 'Supprimer cette ligne ?',
'bud_new_transaction' => 'Nouvelle transaction',
'bud_assign_to_month' => 'Imputer au mois de gestion',
'bud_real_date' => 'Date réelle de l\'opération',
'bud_type' => 'Type',
'bud_type_expense' => 'Dépense',
'bud_type_income' => 'Revenu / Remboursement',
'bud_beneficiary' => 'Bénéficiaire',
'bud_fixed_charge' => 'Charge Fixe',
'bud_expected_income' => 'Revenu Attendu',
'bud_amount_eur' => 'Montant (€)',
'bud_update_balance' => 'MàJ Solde Bancaire',
'bud_balance_eur' => 'Solde (€)',
'bud_add_title' => 'Ajouter',
'bud_edit_title' => 'Modifier la transaction',
'bud_to_define_js' => 'à définir',
'bud_bar_actual' => "Actuel",
'bud_bar_theoretical' => "Théorique",
'bud_bar_max_cap' => "Capacité Max",
'bud_import_csv' => "Importer CSV",
'bud_csv_file' => "Sélectionnez le fichier de votre banque (.csv)",
'bud_preview' => "Prévisualiser",
'bud_validate_import' => "Veuillez vérifier et catégoriser les lignes avant validation.",
'bud_assign_month' => "Mois de gestion",
'bud_op_date' => "Date",
'bud_amount' => "Montant",
'bud_ignore' => "Ignorer (Crédit)",
'bud_to_define' => "À définir",
'bud_is_charge' => "Est une charge fixe...",
'bud_is_income' => "Est un revenu...",
// --- BUDGET : RECAP ---
'bud_recap_monthly_title' => 'Récapitulatif Mensuel',
'bud_recap_btn_add' => 'Ajouter un frais / revenu',
'bud_planned_amount' => 'Montant Prévu',
'bud_day' => 'Jour',
'bud_month_state' => 'État du mois',
'bud_est_short' => 'Est.',
'bud_reg_planned_in' => 'Régul. prévue en %s',
'bud_bonus' => 'Bonus',
'bud_overrun' => 'Dépassement',
'bud_remaining' => 'Reste',
'bud_exact_amount' => 'Montant exact',
'bud_per_month_short' => 'Soit',
'bud_month_short' => 'mois',
'bud_freq_mensuel' => 'Mensuel',
'bud_freq_annuel' => 'Annuel',
'bud_state_validated' => 'Validé',
'bud_state_partial' => 'Partiel',
'bud_state_waiting' => 'En attente',
'bud_total_income_smoothed' => 'Total Revenus (Lissés)',
'bud_total_expenses_smoothed'=> 'Total Dépenses (Lissées)',
'bud_theoretical_balance_recap'=> 'Équilibre théorique',
'bud_recap_footer_note' => 'L\'état de paiement se met à jour automatiquement en fonction des opérations importées dans l\'onglet "Suivi Mensuel".',
'bud_recap_modal_add' => 'Ajouter un élément',
'bud_recap_modal_edit' => 'Modifier',
'bud_label_name' => 'Nom',
'bud_ph_item_name' => 'Ex: Loyer, Salaire...',
'bud_label_keywords' => 'Mots-clés (Détection Auto)',
'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (séparés par virgule)',
'bud_help_keywords' => 'Si une dépense du mois contient ce mot, la ligne passera en "Validé".',
'bud_label_day' => 'Jour (1-31)',
'bud_cat_income' => 'Épargne',
'bud_cat_expense' => 'Dépense (Frais)',
'bud_label_frequency' => 'Fréquence',
'bud_label_amount_type' => 'Type de montant',
'bud_type_fixed' => 'Fixe (Facture)',
'bud_type_variable' => 'Variable (Estimation)',
'bud_label_regularization' => 'Régularisation',
'bud_reg_none' => 'Aucune',
'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?',
'bud_err_delete' => 'Erreur lors de la suppression.',
// --- BUDGET : EPARGNE ---
'bud_sav_add_month' => 'Saisir un mois',
'bud_sav_add_one_month' => '+1 Mois',
'bud_sav_no_data' => 'Aucune donnée pour %s.',
'bud_sav_post_month' => 'Poste / Mois',
'bud_sav_from_date' => 'Dès le %s',
'bud_sav_edit_modal' => 'Modifier avec Modale',
'bud_sav_delete_month' => 'Supprimer tout le mois',
'bud_sav_total_bank' => 'Total Banque',
'bud_sav_extra' => 'Extra',
'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_modal_title_edit' => 'Modifier :',
'bud_sav_month_concerned' => 'Mois concerné',
'bud_sav_total_bank_eur' => 'Total en Banque (€)',
'bud_sav_ventilation' => 'Ventilation',
'bud_sav_adj_help' => 'Utilisez l\'ajustement (+/-) pour recalculer automatiquement.',
'bud_sav_add_line' => 'Ligne',
'bud_sav_current' => 'Actuel',
'bud_sav_adjust' => 'Ajust (+/-)',
'bud_sav_new' => 'Nouveau',
'bud_sav_sum_mode_title' => 'Activer l\'addition rapide',
'bud_sav_selection' => 'Sélection :',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_sav_saving' => 'Enregistrement...',
'bud_err_tech' => 'Une erreur technique est survenue.',
'bud_sav_confirm_delete_month' => 'Supprimer TOUT le mois de %m pour %o ?',
'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :",
'bud_err_server' => 'Erreur serveur : ',
'bud_err_network_dup' => 'Erreur réseau lors de la duplication.',
// --- BUDGET : PREVISIONNEL ---
'bud_prev_incomes' => 'Revenus',
'bud_prev_person' => 'Personne',
'bud_prev_salary' => 'Salaire',
'bud_prev_monthly_payment' => 'Mensualité',
'bud_prev_joint_account' => 'Cpt Commun',
'bud_prev_func_expenses' => 'Frais Func.',
'bud_prev_perso_savings' => 'Eco Perso',
'bud_prev_family_savings' => 'Eco Family',
'bud_prev_available' => 'Dispo',
'bud_prev_perso_remaining' => 'Restant Perso',
'bud_prev_budget_alloc' => 'Répartition Budgétaire',
'bud_prev_today' => 'Auj.',
'bud_prev_new_line' => 'Nouvelle Ligne',
'bud_prev_remaining' => 'Restant',
'bud_prev_confirm_del_line' => 'Supprimer cette ligne et tout son historique ?',
'bud_prev_notes_for' => 'Notes pour',
'bud_prev_saved' => 'Enregistré',
'bud_prev_notes_ph' => 'Écrivez vos remarques, rappels ou commentaires pour ce mois ici...',
'bud_prev_save_note' => 'Enregistrer la note',
'bud_prev_transfers_to_make' => 'Virements à effectuer',
'bud_prev_destination' => 'Destination',
'bud_prev_done' => 'FAIT',
'bud_prev_validate' => 'Valider',
'bud_prev_grand_total' => 'GRAND TOTAL',
'bud_prev_new_line_title' => 'Nouvelle Ligne de Budget',
'bud_prev_edit_line_title' => 'Modifier la ligne',
'bud_prev_label_target' => 'Cible (Destination)',
'bud_prev_choose' => 'Choisir',
'bud_prev_link_holiday' => 'Lier à un voyage (Optionnel)',
'bud_prev_no_link' => 'Ne pas associer',
'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.',
'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.",
'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.",
'bud_sav_modal_title_add' => 'Saisir un mois',
'bud_sav_ph_name' => 'Nom (ex: Vacances)',
'bud_prev_label_name' => 'Nom',
// ==========================================
// CADEAUX (GIFTS)
// ==========================================
'gift_page_title' => 'HouseHub - Liste de cadeaux',
'gift_occ_tio' => 'Tió',
'gift_occ_noel' => 'Noël',
'gift_occ_rois' => 'Rois mages',
'gift_occ_anniv' => 'Anniversaire',
'gift_occ_sant' => 'Saint',
'gift_main_title' => 'Liste de cadeaux %s',
'gift_aria_change_view' => 'Changer la vue',
'gift_view_nadal' => 'Noël',
'gift_view_anniv' => 'Anniversaires',
'gift_view_by_party' => 'Vue par fête',
'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.',
'gift_add_gift' => 'Ajouter un cadeau',
'gift_paid_by' => 'payé par %s',
'gift_summary_title' => 'Résumé du budget',
'gift_col_adult' => 'Adulte',
'gift_col_child' => 'Enfant',
'gift_col_party' => 'Fête',
'gift_debtor' => 'Débiteur',
'gift_creditor' => 'Créancier',
'gift_liquidations' => 'Tricount',
'gift_no_debt' => 'Aucune dette en cours.',
'gift_owes' => 'doit',
'gift_to' => 'à',
'gift_detailed_list' => 'Liste détaillée des cadeaux',
'gift_col_gift' => 'Cadeau',
'gift_col_link' => 'Lien',
'gift_modal_title_add' => 'Ajouter un cadeau pour %s',
'gift_modal_title_edit' => 'Modifier le cadeau',
'gift_modal_payer' => 'Payé par',
'gift_modal_gift_name' => 'Nom du cadeau',
'gift_modal_ph_name' => 'ex: Lego Star Wars',
'gift_modal_price' => 'Prix (€)',
'gift_modal_link' => 'Lien (optionnel)',
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
'gift_filter_all_children' => 'Tous les enfants',
'gift_filter_all_adults' => 'Tous les adultes',
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.',
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.',
'gift_view_matrix' => 'Voir la matrice détaillée',
// ==========================================
// GARAGE MANAGER
// ==========================================
'menu_garage' => 'Garage',
'garage_page_title' => 'HouseHub - Garage',
'garage_stat_vehicles' => 'Véhicules',
'garage_stat_maintenances' => 'Entretiens',
'garage_stat_parts' => 'Pièces',
'garage_stat_cost' => 'Coût total',
'garage_my_vehicles' => 'Mes véhicules',
'garage_reminders' => 'Rappels & prochains entretiens',
'garage_vehicles_title' => 'Mes véhicules',
'garage_all_parts' => 'Toutes les pièces',
'garage_parts_count' => 'pièces',
'garage_maintenances' => 'Entretiens',
'garage_parts' => 'Pièces',
'garage_add' => 'Ajouter',
'garage_add_vehicle' => 'Ajouter un véhicule',
'garage_add_maintenance' => 'Ajouter un entretien',
'garage_add_part' => 'Ajouter une pièce',
'garage_vehicle_name' => 'Nom du véhicule',
'garage_brand' => 'Marque',
'garage_model' => 'Modèle',
'garage_year' => 'Année',
'garage_plate' => 'Immatriculation',
'garage_fuel' => 'Carburant',
'garage_km' => 'Kilométrage actuel',
'garage_color' => 'Couleur',
'garage_purchase_date' => 'Date d\'achat',
'garage_purchase_price' => 'Prix d\'achat (€)',
'garage_photo' => 'Photo',
'garage_notes' => 'Notes',
'garage_maint_type' => 'Type d\'entretien',
'garage_date' => 'Date',
'garage_km_at' => 'Kilométrage au moment',
'garage_labor_cost' => 'Coût main d\'œuvre (€)',
'garage_description' => 'Description',
'garage_mechanic' => 'Mécanicien',
'garage_garage' => 'Garage / Atelier',
'garage_next_reminder' => 'Prochain entretien (rappel)',
'garage_next_date' => 'Date prévue',
'garage_next_km' => 'Kilométrage prévu',
'garage_part_name' => 'Nom de la pièce',
'garage_category' => 'Catégorie',
'garage_reference' => 'Référence',
'garage_unit_price' => 'Prix unitaire (€)',
'garage_quantity' => 'Quantité',
'garage_unit' => 'Unité',
'garage_supplier' => 'Fournisseur',
'garage_change_photo' => 'Changer la photo',
'garage_new_photo' => 'Nouvelle photo',
'garage_update_photo' => 'Mettre à jour',
];
+21
View File
@@ -0,0 +1,21 @@
<?php
// Connexion à la base meta (gestion des familles et utilisateurs)
$meta_host = getenv('DB_HOST') ?: 'househub-db';
$meta_db = 'househub_meta';
$meta_user = getenv('DB_USER') ?: 'househub';
$meta_pass = getenv('DB_PASS') ?: 'changeme';
try {
$meta_pdo = new PDO(
"mysql:host=$meta_host;dbname=$meta_db;charset=utf8mb4",
$meta_user,
$meta_pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (\PDOException $e) {
die(json_encode(['error' => 'Meta DB unavailable: ' . $e->getMessage()]));
}
-18
View File
@@ -1,18 +0,0 @@
<?php
$pageTitle = "PachaFamily - Accueil";
$activePage = "home";
require __DIR__ . '/header.php';
?>
<h1>Bienvenue sur PachaFamily</h1>
<p>Centre de controle de l'organisation familiale.</p>
<section class="pf-section">
<h2>Modules</h2>
<ul>
<li><a href="/family-calendar.php">Family calendar (conges, vacances, garde)</a></li>
</ul>
</section>
<?php
require __DIR__ . '/footer.php';
+145
View File
@@ -0,0 +1,145 @@
<?php
// Protection de la page : nécessite d'être connecté
require __DIR__ . '/includes/auth.php';
require_login('/login.php');
// Inclusion i18n (déjà fait dans header, mais par sécurité si besoin de tr() avant)
require_once __DIR__ . '/includes/i18n.php';
// Configuration de la page
$pageTitle = tr('home_title');
$activePage = "home";
// background géré par home.css via body[data-page="home"]
$pageCss = "/modules/home/home.css";
require __DIR__ . '/header.php';
// Override background si image personnalisée uploadée
$_fid = $_SESSION['user']['family_id'] ?? null;
$_has_custom_bg = false;
if ($_fid) {
foreach (glob('/uploads/home_bg_' . $_fid . '.*') as $_f) { $_has_custom_bg = true; break; }
}
if ($_has_custom_bg): ?>
<style>body[data-page="home"] { background-image: url('/home-bg.php?v=<?= filemtime($_f) ?>'); }</style>
<?php endif; ?>
<div class="pf-container">
<div class="pf-hero">
<h1><?= tr('home_welcome') ?></h1>
<p><?= tr('home_subtitle') ?></p>
<?php if (isset($_SESSION['user'])): ?>
<div class="pf-user-info" style="margin-top: 12px; font-weight: 500;">
<?= tr('home_logged_as') ?>
<strong><?= htmlspecialchars($_SESSION['user']['display_name'] ?? $_SESSION['user']['username']) ?></strong>
</div>
<?php endif; ?>
</div>
<section class="pf-section">
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage','calendar_ios']; ?>
<div class="pf-modules-grid">
<?php if (in_array('calendar', $mods)): ?>
<a href="/family-calendar.php" class="pf-module-card">
<div class="pf-card-icon">📅</div>
<h3 class="pf-card-title"><?= tr('mod_calendar_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_calendar_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_open') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('budget', $mods)): ?>
<a href="/budget.php" class="pf-module-card">
<div class="pf-card-icon">💰</div>
<h3 class="pf-card-title"><?= tr('mod_budget_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_budget_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_manage') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('holidays', $mods)): ?>
<a href="/holidays.php" class="pf-module-card">
<div class="pf-card-icon">🏖️</div>
<h3 class="pf-card-title"><?= tr('mod_holidays_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_holidays_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_explore') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('gifts', $mods)): ?>
<a href="/gift-list.php" class="pf-module-card">
<div class="pf-card-icon">🎁</div>
<h3 class="pf-card-title"><?= tr('mod_gifts_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_gifts_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_view_lists') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('garage', $mods)): ?>
<a href="/garage.php" class="pf-module-card">
<div class="pf-card-icon">🚗</div>
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_service') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('memo', $mods)): ?>
<a href="/memo.php" class="pf-module-card">
<div class="pf-card-icon">📝</div>
<h3 class="pf-card-title"><?= tr('mod_memo_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_memo_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_jot') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('todo', $mods)): ?>
<a href="/todo.php" class="pf-module-card">
<div class="pf-card-icon">✅</div>
<h3 class="pf-card-title"><?= tr('mod_todo_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_todo_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_check') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('liste', $mods)): ?>
<a href="/liste.php" class="pf-module-card">
<div class="pf-card-icon">🛒</div>
<h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?>
<a href="/calendar-ios.php" class="pf-module-card">
<div class="pf-card-icon">📱</div>
<h3 class="pf-card-title"><?= tr('mod_calendar_ios_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_calendar_ios_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_sync') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('printvault', $mods)): ?>
<a href="/printvault.php" class="pf-module-card">
<div class="pf-card-icon">🖨️</div>
<h3 class="pf-card-title"><?= tr('mod_printvault_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_printvault_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_print') ?></span>
</a>
<?php endif; ?>
</div>
</section>
</div>
<?php
require __DIR__ . '/footer.php';
?>
+70
View File
@@ -0,0 +1,70 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
$activePage = 'liste';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
<main 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>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></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>
</main>
<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')) ?>,
};
</script>
<script src="/modules/liste/assets/liste.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+110
View File
@@ -0,0 +1,110 @@
<?php
session_start();
require_once __DIR__ . '/includes/i18n.php';
require_once __DIR__ . '/includes/meta_db.php';
require_once __DIR__ . '/includes/auth.php';
$pageTitle = tr('login_title');
$activePage = "login";
if (isset($_SESSION['user'])) {
header('Location: /index.php');
exit;
}
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
$error = "Session invalide (CSRF). Rechargez la page.";
} else {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (!$username || !$password) {
$error = tr('error_missing_fields');
} else {
$stmt = $meta_pdo->prepare("
SELECT u.id, u.username, u.password_hash, u.display_name, u.family_id, u.is_admin, u.is_active, u.lang,
f.db_name, f.is_active as family_active, f.enabled_modules
FROM users u
LEFT JOIN families f ON f.id = u.family_id
WHERE u.username = ?
");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
if (!$user['is_active']) {
$error = "Compte désactivé. Contactez l'administrateur.";
} elseif ($user['family_id'] && !$user['family_active']) {
$error = "Espace familial désactivé. Contactez l'administrateur.";
} else {
session_regenerate_id(true);
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
'display_name' => $user['display_name'],
'family_id' => (int)$user['family_id'],
'is_admin' => (bool)$user['is_admin'],
];
$_SESSION['family_db'] = $user['db_name'];
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
$redirectTo = $_GET['redirect'] ?? '/index.php';
header('Location: ' . $redirectTo);
exit;
}
} else {
$error = tr('error_invalid_credentials');
}
}
}
}
require __DIR__ . '/header.php';
?>
<div class="pf-container pf-login-wrapper">
<div class="pf-login-card">
<header class="pf-login-header">
<img src="/favicon.png" alt="HouseHub Logo" class="pf-login-icon">
<h1><?= tr('login_header') ?></h1>
<p><?= tr('login_subtitle') ?></p>
</header>
<?php if ($error): ?>
<div class="pf-login-error">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="pf-form-group">
<label class="pf-label" for="username"><?= tr('label_username') ?></label>
<input type="text" id="username" name="username" class="pf-input"
required autofocus placeholder="<?= tr('placeholder_username') ?>"
autocomplete="username" autocapitalize="none">
</div>
<div class="pf-form-group">
<label class="pf-label" for="password"><?= tr('label_password') ?></label>
<input type="password" id="password" name="password" class="pf-input"
required placeholder="••••••" autocomplete="current-password">
</div>
<button type="submit" class="pf-btn pf-btn-block">
<?= tr('btn_login_submit') ?>
</button>
</form>
<p style="text-align:center;margin-top:20px;font-size:0.9rem;color:#64748b">
Pas encore de compte ? <a href="/register.php" style="color:var(--primary)">Créer un espace</a>
</p>
</div>
</div>
<?php require __DIR__ . '/footer.php'; ?>
+26
View File
@@ -0,0 +1,26 @@
<?php
session_start();
// On vide toutes les variables de session
$_SESSION = [];
// On détruit la session
session_destroy();
// Optionnel : supprimer le cookie de session (plus propre)
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
// On renvoie vers la page de login (ou index si tu préfères)
header('Location: /login.php');
exit;
+139
View File
@@ -0,0 +1,139 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'Notes — HouseHub';
$activePage = 'memo';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/memo/assets/memo.css">
<div id="memo-toasts" class="memo-toast-container"></div>
<div class="memo-layout">
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
<aside class="memo-sidebar" id="memo-sidebar">
<div class="memo-sidebar-top">
<button class="btn btn-primary" onclick="openCreate()" style="width:100%;justify-content:center">+ Nouvelle note</button>
<div class="memo-search">
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
<input type="text" id="memo-search-input" placeholder="Rechercher…">
</div>
</div>
<div class="memo-tags-list" id="memo-tags-list"></div>
</aside>
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
<div class="memo-main">
<!-- Page : liste ─────────────────────────────────────────────────────── -->
<div id="memo-page-list" class="memo-page active">
<div class="memo-list-header">
<div>
<div id="memo-list-title" style="font-size:1rem;font-weight:700">📝 Notes</div>
<div id="memo-list-subtitle" class="memo-list-subtitle"></div>
</div>
<button class="btn btn-primary btn-sm" onclick="openCreate()">+ Nouvelle note</button>
</div>
<div id="memo-notes-grid" class="notes-grid"></div>
<div id="memo-pagination" style="display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem 1.5rem;gap:.5rem"></div>
</div>
<!-- Page : vue note ──────────────────────────────────────────────────── -->
<div id="memo-page-view" class="memo-page">
<div class="memo-view-header">
<div class="memo-view-title" id="view-title"></div>
<div class="memo-view-meta">
<div id="view-tags" style="display:flex;gap:.35rem;flex-wrap:wrap"></div>
<span id="view-date" style="margin-left:auto"></span>
<div class="memo-view-actions">
<button class="btn btn-secondary btn-sm" onclick="loadList(currentQ,currentTag)">← Retour</button>
<button class="btn btn-secondary btn-sm" onclick="openEdit(currentNoteId)">✏️ Modifier</button>
<button class="btn btn-danger btn-sm" onclick="deleteNote(currentNoteId)">🗑️</button>
</div>
</div>
</div>
<div class="memo-content-area">
<div class="md-body" id="view-md"></div>
</div>
<div class="memo-attachments" id="view-attachments" style="display:none"></div>
</div>
<!-- Page : édition ───────────────────────────────────────────────────── -->
<div id="memo-page-edit" class="memo-page">
<div class="memo-edit-area">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem;flex-wrap:wrap;gap:.5rem">
<h2 id="edit-page-title" style="font-size:1rem;font-weight:700;margin:0">Nouvelle note</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-secondary" onclick="currentNoteId?viewNote(currentNoteId):loadList()">Annuler</button>
<button class="btn btn-primary" onclick="saveNote()">💾 Enregistrer</button>
</div>
</div>
<div class="memo-edit-form">
<!-- Titre -->
<div class="form-group">
<label class="form-label">Titre *</label>
<input type="text" id="edit-title" class="form-control" placeholder="Titre de la note…" required>
</div>
<!-- Contenu + preview -->
<div class="form-group">
<label class="form-label">Contenu <span style="font-size:.72rem;color:var(--text-muted)">(Markdown supporté)</span></label>
<div class="edit-split">
<textarea id="edit-content" class="form-control edit-textarea" placeholder="Écrivez ici en Markdown…"></textarea>
<div class="md-preview-box md-body" id="md-live-preview" style="min-height:320px"></div>
</div>
</div>
<!-- Tags -->
<div class="form-group">
<label class="form-label">Tags</label>
<div class="tags-input-wrap" onclick="document.getElementById('edit-tags-input').focus()">
<div id="edit-tags-chips" style="display:contents"></div>
<input type="text" id="edit-tags-input" placeholder="Ajouter un tag…" autocomplete="off">
</div>
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">Appuyez sur Entrée ou virgule pour valider</div>
</div>
<!-- Fichiers -->
<div class="form-group">
<label class="form-label">Fichiers & Images</label>
<div id="edit-drop-zone" class="attach-drop">
📎 Glissez-déposez des fichiers ou cliquez pour choisir (images, PDF…)
<input type="file" id="edit-file-input" multiple accept="image/*,.pdf,.doc,.docx,.txt,.csv,.zip" style="display:none">
</div>
<div id="edit-attach-preview" class="attach-preview-list"></div>
<div id="edit-existing-attachments" style="margin-top:.75rem"></div>
</div>
<!-- URLs -->
<div class="form-group">
<label class="form-label">Liens / URLs</label>
<div id="edit-url-rows"></div>
</div>
</div>
</div>
</div>
</div><!-- memo-main -->
</div><!-- memo-layout -->
<!-- Lightbox -->
<div id="memo-lightbox" class="lightbox">
<img src="" alt="" onclick="event.stopPropagation()">
</div>
<!-- marked.js pour le rendu Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
if(window.marked){
marked.setOptions({breaks:true,gfm:true});
}
</script>
<script src="/modules/memo/assets/memo.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+46
View File
@@ -0,0 +1,46 @@
<?php
// Script de migration pour mettre à jour TOUTES les bases de données familiales
require_once __DIR__ . '/includes/meta_db.php';
echo "<h1>🚀 Début de la migration...</h1>";
// 1. Récupérer toutes les bases de données des familles
$stmt = $meta_pdo->query("SELECT name, db_name FROM families WHERE db_name != ''");
$families = $stmt->fetchAll();
$host = getenv('DB_HOST') ?: 'househub-db';
$user = getenv('DB_USER') ?: 'househub';
$pass = getenv('DB_PASS') ?: 'changeme';
// 2. Boucler sur chaque famille
foreach ($families as $f) {
$db_name = $f['db_name'];
echo "Mise à jour de la famille <strong>{$f['name']}</strong> (Base : $db_name) ... ";
try {
$fam_pdo = new PDO("mysql:host=$host;dbname=$db_name;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 3. Injecter la nouvelle table
$sql = "
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT '€',
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C');
";
$fam_pdo->exec($sql);
echo "<span style='color:green'>✅ OK</span><br>";
} catch (\PDOException $e) {
echo "<span style='color:red'>❌ Erreur : " . $e->getMessage() . "</span><br>";
}
}
echo "<h2>🎉 Migration terminée ! Tu peux supprimer ce fichier.</h2>";
?>
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// --- ACTION : SAUVEGARDER (AJOUT OU MODIF) ---
if ($action === 'save') {
$id = $_POST['id'] ?? '';
$name = $_POST['name'];
$category = $_POST['category'];
$type = $_POST['type'];
$payment_day = empty($_POST['payment_day']) ? null : (int)$_POST['payment_day'];
$is_estimate = $_POST['is_estimate'];
$reg_month = $_POST['reg_month'];
$keywords = $_POST['mapping_keywords'] ?? '';
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
// ==========================================
// NOUVELLE NORME COMPTABLE : Dépense = Négatif
// ==========================================
// On récupère le montant en valeur absolue (pour éviter les erreurs si l'utilisateur a tapé un '-')
$amount = abs((float)$_POST['amount']);
// Si c'est un frais, on l'enregistre en négatif
if ($category === 'expense') {
$amount = -$amount;
}
// ==========================================
if ($id) {
// UPDATE
$stmt = $pdo->prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?");
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]);
} else {
// INSERT
$stmt = $pdo->prepare("INSERT INTO pf_budget_items (name, amount, category, type, payment_day, is_estimate, reg_month, mapping_keywords, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]);
}
// Redirection
header('Location: /budget.php?tab=recap');
exit;
}
// --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) ---
if ($action === 'toggle-check') {
$id = $_POST['id'];
$status = $_POST['status'];
$sql = "UPDATE pf_budget_items SET is_checked = ? WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$status, $id]);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
// --- ACTION : SUPPRIMER ---
if ($action === 'delete') {
$id = $_POST['id'];
$pdo->prepare("DELETE FROM pf_budget_items WHERE id = ?")->execute([$id]);
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
} else {
header('Location: /budget.php?tab=recap');
}
exit;
}
// --- ACTION : SUPPRIMER UNE DÉPENSE RÉELLE (Depuis l'onglet Suivi) ---
if ($action === 'delete_expense') {
$id = (int)$_POST['id'];
$pdo->prepare("DELETE FROM pf_expenses WHERE id = ?")->execute([$id]);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
// --- ACTION : SAUVEGARDER UNE DÉPENSE MANUELLE (FETCH) ---
if ($action === 'save_expense_manual') {
header('Content-Type: application/json'); // On force le retour JSON pour notre script JS
try {
$id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null;
$cat = $_POST['category'] ?? '';
$amount = floatval($_POST['amount'] ?? 0);
$date = $_POST['date'] ?? date('Y-m-d');
// 🛡️ SÉCURITÉ : Format attendu pour gestion_month (qui vient de input type="month") : YYYY-MM.
$gestionMonthRaw = $_POST['gestion_month'] ?? '';
// Si la valeur est vide ou '0000-00-00'
if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) {
// On force le mois de gestion au 1er jour de la date de la dépense
$gestionMonth = date('Y-m-01', strtotime($date));
} else {
// Si la valeur vient d'un input "month" (ex: "2026-04"), on rajoute "-01"
$gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw;
}
$label = trim($_POST['label'] ?? '');
$budgetItemId = null;
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($cat === 'School' && !empty($_POST['label_select'])) {
$label = trim($_POST['label_select']);
} elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) {
$budgetItemId = (int)$_POST['budget_item_id'];
}
// Vérification de sécurité
if (empty($label) || $amount <= 0) {
echo json_encode(['success' => false, 'error' => 'Le label et un montant supérieur à 0 sont obligatoires.']);
exit;
}
$is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0;
$finalAmount = $is_credit ? abs($amount) : -abs($amount);
if ($id) {
// UPDATE
$pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]);
} else {
// INSERT
$uniqueRef = "MANUAL_" . uniqid();
$pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]);
}
echo json_encode(['success' => true]);
exit;
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => 'Erreur base de données.']);
exit;
}
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
// modules/budget/includes/api/save-budget.php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
$action = $_POST['action'] ?? '';
// =================================================================
// 7. SAUVEGARDE D'UNE NOTE GÉNÉRIQUE (pf_notes)
// =================================================================
if ($action === 'save_note') {
// On affiche les erreurs s'il y a un souci SQL pour pouvoir déboguer
ini_set('display_errors', 1);
error_reporting(E_ALL);
header('Content-Type: application/json');
try {
$noteType = $_POST['note_type'] ?? '';
$refId = $_POST['reference_id'] ?? '';
$content = $_POST['content'] ?? '';
if (empty($noteType) || empty($refId)) {
throw new Exception("Le type et la référence de la note sont requis.");
}
// Insère ou met à jour la note si elle existe déjà
$stmt = $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE content = VALUES(content)");
$stmt->execute([$noteType, $refId, $content]);
echo json_encode(['success' => true]);
} catch (\Throwable $e) {
echo json_encode(['success' => false, 'error' => 'Erreur SQL: ' . $e->getMessage()]);
}
exit;
}
// =================================================================
// 1. MISE A JOUR TABLEAU SALAIRES (AJAX)
if ($action === 'update_salary_config') {
header('Content-Type: application/json'); // On précise JSON ici
$year = $_POST['year'];
$person = $_POST['person'];
$field = $_POST['field']; // salary, mensualite, etc.
$value = floatval($_POST['value']);
// Liste des champs autorisés pour éviter les injections
$allowed = ['salary', 'mensualite', 'frais_func', 'eco_perso', 'eco_family'];
if (!in_array($field, $allowed)) { echo json_encode(['error'=>'Champ invalide']); exit; }
$stmt = $pdo->prepare("INSERT INTO pf_salary_config (year, person, $field) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $field = VALUES($field)");
$stmt->execute([$year, $person, $value]);
echo json_encode(['success' => true]);
exit;
}
// 2. MISE A JOUR TABLEAU REPARTITION (AJAX)
if ($action === 'update_allocation') {
header('Content-Type: application/json');
$date = $_POST['month_date'];
$catId = $_POST['cat_id'];
$person = $_POST['person']; // 'amount_alex' ou 'amount_laia'
$value = floatval($_POST['value']);
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, $person) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $person = VALUES($person)");
$stmt->execute([$date, $catId, $value]);
echo json_encode(['success' => true]);
exit;
}
// 3. GESTION DES CATEGORIES (Ajout)
if ($action === 'add_category') {
$name = trim($_POST['name']);
$target = trim($_POST['target']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if (!empty($name)) {
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)");
$stmt->execute([$name, $target, $holiday_id]);
}
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
}
// 4. MODIFICATION D'UNE CATEGORIE
if ($action === 'update_category') {
$id = (int)$_POST['cat_id'];
$name = trim($_POST['name']);
$target = trim($_POST['target']);
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
if ($id > 0 && !empty($name)) {
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?");
$stmt->execute([$name, $target, $holiday_id, $id]);
}
// --- NOUVEAU : Réponse AJAX ---
if (isset($_POST['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . $_SERVER['HTTP_REFERER']);
exit;
}
// 5. SUPPRESSION CATEGORIE
if ($action === 'delete_category') {
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id > 0) {
$pdo->prepare("DELETE FROM pf_alloc_categories WHERE id = ?")->execute([$id]);
$pdo->prepare("DELETE FROM pf_alloc_values WHERE cat_id = ?")->execute([$id]);
}
if (isset($_POST['ajax']) || isset($_GET['ajax'])) {
header('Content-Type: application/json');
echo json_encode(['success' => true]);
exit;
}
header("Location: " . ($_SERVER['HTTP_REFERER'] ?? '/budget.php'));
exit;
}
// 6. VALIDATION DES VIREMENTS (Complex Business Logic)
if ($action === 'validate_transfers') {
header('Content-Type: application/json');
$person = $_POST['person'];
$monthDate = $_POST['month_date'];
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("
SELECT v.*, c.name as cat_name, c.target, c.holiday_id
FROM pf_alloc_values v
JOIN pf_alloc_categories c ON v.cat_id = c.id
WHERE v.month_date = ?
");
$stmt->execute([$monthDate]);
$budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC);
$transfersToDo = [];
foreach ($budgetLines as $line) {
$amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia'];
if ($amount <= 0) continue;
$target = trim($line['target']);
$catName = trim($line['cat_name']);
$holidayId = $line['holiday_id'];
$targetOwner = null;
if ($target === 'vers L.Perso') { $targetOwner = $person; }
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; }
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; }
elseif ($target === 'vers commune') { continue; }
if ($targetOwner) {
if (!isset($transfersToDo[$targetOwner])) {
$transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []];
}
$transfersToDo[$targetOwner]['total_add'] += $amount;
if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) {
$transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId];
}
$transfersToDo[$targetOwner]['cats'][$catName]['amount'] += $amount;
}
}
foreach ($transfersToDo as $owner => $data) {
// A. VERIFIER EXISTENCE (Inchangé)
$stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtCheck->execute([$owner, $monthDate]);
$exists = $stmtCheck->fetchColumn() > 0;
if (!$exists) {
$prevDate = date('Y-m-d', strtotime($monthDate . ' -1 month'));
$stmtPrev = $pdo->prepare("SELECT category, amount, holiday_id FROM pf_savings WHERE owner = ? AND month_date = ?");
$stmtPrev->execute([$owner, $prevDate]);
$prevLines = $stmtPrev->fetchAll(PDO::FETCH_ASSOC);
if (empty($prevLines)) { $prevLines = [['category' => 'TOTAL_BANQUE', 'amount' => 0, 'holiday_id' => null]]; }
$stmtInsert = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
foreach ($prevLines as $row) {
$stmtInsert->execute([$monthDate, $owner, $row['category'], $row['amount'], $row['holiday_id']]);
}
}
// B. UPDATE TOTAL (Inchangé)
$stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
$stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]);
// C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id)
foreach ($data['cats'] as $catName => $catInfo) {
$catAmount = $catInfo['amount'];
$catHolidayId = $catInfo['holiday_id']; // NOUVEAU
if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; }
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmtCheckCat->execute([$owner, $monthDate, $catName]);
$catId = $stmtCheckCat->fetchColumn();
if ($catId) {
// Update : On actualise aussi le holiday_id au cas où il aurait changé
$stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?");
$stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]);
} else {
// Insert
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
$stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]);
}
}
}
// 3. ENREGISTRER LA VALIDATION (Mise à jour table existante)
// a. Trouver l'ID de la catégorie système
$stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1");
$stmtSys->execute();
$sysCatId = $stmtSys->fetchColumn();
if ($sysCatId) {
if ($person === 'Alex') {
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
VALUES (?, ?, 1, 0)
ON DUPLICATE KEY UPDATE amount_alex = 1";
} else {
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
VALUES (?, ?, 0, 1)
ON DUPLICATE KEY UPDATE amount_laia = 1";
}
$stmtVal = $pdo->prepare($sql);
$stmtVal->execute([$monthDate, $sysCatId]);
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
@@ -0,0 +1,140 @@
<?php
require __DIR__ . '/../../../../includes/auth.php';
require __DIR__ . '/../../../../includes/db.php';
require_login();
// =================================================================
// MISE À JOUR D'UNE CELLULE EN DIRECT (AJAX)
// =================================================================
if ($action === 'update_single_entry') {
header('Content-Type: application/json');
$month = $_POST['month_date'];
$cat = $_POST['category'];
$owner = $_POST['owner'];
$amount = (float)$_POST['amount'];
try {
if ($amount == 0 && $cat !== 'TOTAL_BANQUE') {
// Si on met à 0 une ligne (autre que le total), on supprime l'entrée pour garder la base propre
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?");
$stmt->execute([$month, $owner, $cat]);
} else {
// Sinon on insère ou on met à jour
$stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)");
$stmt->execute([$month, $owner, $cat, $amount]);
}
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_entry') {
$owner = $_POST['owner'];
$date = $_POST['month_date'];
$cat = $_POST['category'];
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
$stmt->execute([$owner, $date, $cat]);
echo json_encode(['success' => true]);
exit;
}
// --- ACTION : DUPLICATION DE MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'duplicate_month') {
$owner = $_POST['owner'];
$sourceDate = $_POST['source_date'];
$targetDate = $_POST['target_date'];
$newTotal = floatval($_POST['new_total']);
try {
$pdo->beginTransaction();
// 1. Vérifier si le mois cible existe déjà
$check = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? LIMIT 1");
$check->execute([$owner, $targetDate]);
if ($check->fetch()) {
echo json_encode(['success' => false, 'error' => 'Ce mois existe déjà !']);
exit;
}
// 2. Insérer le nouveau TOTAL BANQUE
$stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)");
$stmtIns->execute([$owner, $targetDate, 'TOTAL_BANQUE', $newTotal]);
// 3. Copier toutes les catégories (sauf TOTAL_BANQUE) du mois source
$sqlCopy = "INSERT INTO pf_savings (owner, month_date, category, amount)
SELECT owner, ?, category, amount
FROM pf_savings
WHERE owner = ? AND month_date = ? AND category != 'TOTAL_BANQUE'";
$stmtCopy = $pdo->prepare($sqlCopy);
$stmtCopy->execute([$targetDate, $owner, $sourceDate]);
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// --- ACTION : SUPPRESSION GLOBALE D'UN MOIS ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_month_global') {
$owner = $_POST['owner'];
$date = $_POST['month_date'];
try {
$stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?");
$stmt->execute([$owner, $date]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// --- ACTION : SAUVEGARDE CLASSIQUE (MODALE) ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$owner = $_POST['owner'];
// CORRECTION ICI : On récupère l'onglet de redirection (Nens, Alex ou Laia)
$redirectTab = $_POST['redirect_tab'] ?? $owner;
$dateInput = $_POST['month_date'];
$dateObj = new DateTime($dateInput);
$monthDate = $dateObj->format('Y-m-01');
$values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie]
try {
$pdo->beginTransaction();
// On supprime d'abord les anciennes données du mois pour cet utilisateur
$stmtDel = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?");
$stmtDel->execute([$owner, $monthDate]);
// On réinsère les nouvelles données
$stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)");
foreach ($values as $category => $amount) {
$amount = floatval($amount);
// On enregistre si c'est positif OU si c'est le total banque
if ($amount > 0 || $category === 'TOTAL_BANQUE') {
$stmtIns->execute([$owner, $monthDate, $category, $amount]);
}
}
$pdo->commit();
// Redirection vers le bon onglet
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
exit;
} catch (Exception $e) {
$pdo->rollBack();
die("Erreur : " . $e->getMessage());
}
}
+875
View File
@@ -0,0 +1,875 @@
<?php
// modules/budget/views/budget_prev.php
$currentYear = date('Y');
// 1. Récupération Config Salaires
$salaryConfig = [];
$stmt = $pdo->prepare("SELECT * FROM pf_salary_config WHERE year = ?");
$stmt->execute([$currentYear]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$salaryConfig[$row['person']] = $row;
}
foreach (['Alex', 'Laia'] as $p) {
if (!isset($salaryConfig[$p])) {
$salaryConfig[$p] = ['salary'=>0, 'mensualite'=>0, 'frais_func'=>0, 'eco_perso'=>0, 'eco_family'=>0];
}
}
// 2. Récupération Catégories
$cats = $pdo->query("SELECT * FROM pf_alloc_categories ORDER BY sort_order ASC, id ASC")->fetchAll(PDO::FETCH_ASSOC);
// 3. Gestion du mois FOCUS et Navigation
$focusDate = isset($_GET['focus_date']) ? $_GET['focus_date'] : date('Y-m-01');
$focusTs = strtotime($focusDate);
$months = [];
for ($i = 0; $i < 6; $i++) {
$months[] = date('Y-m-01', strtotime("-$i months", $focusTs));
}
$prevMonthLink = date('Y-m-01', strtotime("-1 month", $focusTs));
$nextMonthLink = date('Y-m-01', strtotime("+1 month", $focusTs));
// NOUVEAU : Récupération des Cycles configurés dans pf_notes
$cycleConfigs = [];
$stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'");
while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) {
$parts = explode('-', $row['reference_id']);
if (count($parts) == 2) {
$mKey = $parts[1] . '-' . $parts[0] . '-01';
$cycleConfigs[$mKey] = json_decode($row['content'], true);
}
}
// 4. Récupération Valeurs Répartition
$inQuery = implode(',', array_fill(0, count($months), '?'));
$stmt = $pdo->prepare("SELECT * FROM pf_alloc_values WHERE month_date IN ($inQuery)");
$stmt->execute($months);
$allocs = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$allocs[$row['month_date']][$row['cat_id']] = $row;
}
// 5. Récupération de l'ID de la catégorie système
$sysCatId = null;
foreach ($cats as $key => $c) {
if ($c['name'] === 'SYSTEM_VALIDATION') {
$sysCatId = $c['id'];
unset($cats[$key]);
break;
}
}
// 6. Lecture des statuts de validation
$focusDate = $months[0];
$isValidatedAlex = false;
$isValidatedLaia = false;
if ($sysCatId && isset($allocs[$focusDate][$sysCatId])) {
$row = $allocs[$focusDate][$sysCatId];
$isValidatedAlex = ($row['amount_alex'] == 1);
$isValidatedLaia = ($row['amount_laia'] == 1);
}
$stmtNote = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'budget_prev' AND reference_id = ?");
$stmtNote->execute([$focusDate]);
$currentNote = $stmtNote->fetchColumn();
// 7. Récupération des vacances actives
$activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN ('draft', 'planned', 'booked') ORDER BY start_date ASC")->fetchAll(PDO::FETCH_ASSOC);
// Helper function pour les mois
function getTranslatedMonthName($dateString) {
$m = date('m', strtotime($dateString));
$y = date('Y', strtotime($dateString));
return tr('month_' . $m) . ' ' . $y;
}
?>
<div class="prev-container">
<div>
<div class="prev-section-header">
<h2><?= tr('bud_prev_incomes') ?> <?= $currentYear ?></h2>
</div>
<table class="prev-salary-table">
<thead>
<tr>
<th><?= tr('bud_prev_person') ?></th>
<th><?= tr('bud_prev_salary') ?></th>
<th><?= tr('bud_prev_monthly_payment') ?><br><small style="font-weight:normal; text-transform:none;">(<?= tr('bud_prev_joint_account') ?>)</small></th>
<th><?= tr('bud_prev_func_expenses') ?></th>
<th><?= tr('bud_prev_perso_savings') ?></th>
<th style="background:#f0f9ff; color:#0284c7;"><?= tr('bud_prev_family_savings') ?><br><small style="font-weight:normal; text-transform:none;">(<?= tr('bud_prev_available') ?>)</small></th>
<th><?= tr('bud_prev_perso_remaining') ?></th>
</tr>
</thead>
<tbody>
<?php
// Définition des noms à partir de la config
$family_members = [ID_ALEX => 'Alex', ID_LAIA => 'Laia'];
foreach ($family_members as $id => $p):
$d = $salaryConfig[$p];
$restant = $d['salary'] - ($d['mensualite'] + $d['frais_func'] + $d['eco_perso'] + $d['eco_family']);
$borderColor = ($id === ID_ALEX) ? '#0891b2' : '#f59e0b';
?>
<tr data-person="<?= $p ?>">
<td style="text-align:left; font-weight:bold; color:var(--text-main); border-left:4px solid <?= $borderColor ?>;">
<?= $p ?>
</td>
<td><input type="number" class="prev-input" data-field="salary" value="<?= round($d['salary']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
<td><input type="number" class="prev-input" data-field="mensualite" value="<?= round($d['mensualite']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
<td><input type="number" class="prev-input" data-field="frais_func" value="<?= round($d['frais_func']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
<td><input type="number" class="prev-input" data-field="eco_perso" value="<?= round($d['eco_perso']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
<td style="background:#f0f9ff;">
<input type="number" class="prev-input bold-blue" id="eco_family_<?= $p ?>" data-field="eco_family" value="<?= round($d['eco_family']) ?>" onchange="updateSalary('<?= $p ?>', this)">
</td>
<td id="restant_<?= $p ?>" style="font-weight:bold; color:var(--text-muted); text-align:center;" class="sum-target">
<?= number_format($restant, 0, ',', ' ') ?> €
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div>
<div class="prev-section-header" style="background: #f8fafc; padding: 15px; border-radius: 12px; border: 1px solid #e2e8f0; margin-bottom: 20px;">
<div style="display:flex; gap:10px; align-items:center;">
<h2 style="margin:0; font-size:1.3rem;"><?= tr('bud_prev_budget_alloc') ?></h2>
<div class="nav-group" style="display:flex; gap:5px;">
<a href="?tab=budget_prev&focus_date=<?= $prevMonthLink ?>" class="btn-nav">◀</a>
<a href="?tab=budget_prev&focus_date=<?= date('Y-m-01') ?>" class="btn-nav"><?= tr('bud_prev_today') ?></a>
<a href="?tab=budget_prev&focus_date=<?= $nextMonthLink ?>" class="btn-nav">▶</a>
</div>
</div>
<div style="display:flex; gap:10px;">
<button type="button" class="pf-btn btn-secondary" onclick="duplicateMonth()">
🔁 <?= tr('bud_sav_add_one_month') ?>
</button>
<button type="button" class="pf-btn" onclick="document.getElementById('addCatModal').style.display='flex'; document.body.classList.add('no-scroll');">
<?= tr('bud_prev_new_line') ?>
</button>
</div>
</div>
<div class="prev-timeline-wrapper">
<table class="prev-alloc-table">
<thead>
<tr>
<th class="col-sticky header-cell"></th>
<?php foreach ($months as $month):
$isCurrent = ($month == date('Y-m-01'));
$cls = $isCurrent ? 'current' : '';
?>
<th colspan="3" class="th-month <?= $cls ?>">
<span style="text-transform:capitalize;"><?= getTranslatedMonthName($month) ?></span>
<?php
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
echo "<div style='font-size:0.75rem; font-weight:normal; color:#64748b; margin-top:2px;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</div>";
}
?>
</th>
<?php endforeach; ?>
</tr>
<tr>
<th class="col-sticky header-cell"></th>
<?php foreach ($months as $month): ?>
<th class="th-sub">Global</th>
<th class="th-sub txt-alex">Alex</th>
<th class="th-sub txt-laia">Laia</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr class="row-total">
<td class="col-sticky" style="text-align:right !important;">Total</td>
<?php foreach ($months as $m): ?>
<td class="txt-global sum-target" style="border-left:2px solid #e2e8f0;" id="total_global_<?= $m ?>">0</td>
<td class="txt-alex sum-target" id="total_alex_<?= $m ?>">0</td>
<td class="txt-laia sum-target" id="total_laia_<?= $m ?>">0</td>
<?php endforeach; ?>
</tr>
<tr class="row-restant">
<td class="col-sticky" style="text-align:right !important;"><?= tr('bud_prev_remaining') ?> (Eco Family)</td>
<?php foreach ($months as $m): ?>
<td style="border-left:2px solid #cbd5e1; background:#e2e8f0;">-</td>
<td class="val-ok sum-target" id="restant_alloc_alex_<?= $m ?>">0</td>
<td class="val-ok sum-target" id="restant_alloc_laia_<?= $m ?>">0</td>
<?php endforeach; ?>
</tr>
<?php foreach ($cats as $cat):
$isIndicative = ($cat['name'] === 'Eco Alex' || $cat['name'] === 'Eco Laia');
$rowClass = $isIndicative ? 'row-indicative' : '';
$inputClass = $isIndicative ? 'ignore-calc' : '';
$rowStyle = $isIndicative ? 'background:#f8fafc; color:#94a3b8;' : '';
?>
<tr class="<?= $rowClass ?>" style="<?= $rowStyle ?>">
<td class="col-sticky" style="position:relative; <?= $isIndicative ? 'opacity:0.8;' : '' ?>">
<div style="font-weight:600; color:<?= $isIndicative ? '#64748b' : 'var(--text-main)' ?>;">
<?= htmlspecialchars($cat['name']) ?>
<?php if(!empty($cat['holiday_id'])) echo " 🌴"; ?>
<?php if($isIndicative): ?><span style="font-size:0.7rem; border:1px solid #cbd5e1; border-radius:4px; padding:0 4px; margin-left:5px;">Info</span><?php endif; ?>
</div>
<div style="font-size:0.75rem; color:var(--text-muted); font-style:italic; text-align:right;">
<?= htmlspecialchars($cat['target']) ?>
</div>
<div class="row-actions">
<button type="button"
class="btn-icon-action edit"
title="<?= tr('edit') ?>"
data-id="<?= $cat['id'] ?>"
data-name="<?= htmlspecialchars($cat['name']) ?>"
data-target="<?= htmlspecialchars($cat['target']) ?>"
data-holiday="<?= $cat['holiday_id'] ?? '' ?>"
onclick="openEditModal(this)">
</button>
<button type="button"
onclick="deleteCategory(<?= $cat['id'] ?>)"
class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️
</button>
</div>
</td>
<?php foreach ($months as $m):
$val = $allocs[$m][$cat['id']] ?? ['amount_alex'=>0, 'amount_laia'=>0];
?>
<td class="txt-global sum-target" style="border-left:2px solid #e2e8f0; <?= $isIndicative?'color:#94a3b8; font-weight:normal;':'' ?>" id="g_<?= $m ?>_<?= $cat['id'] ?>">0</td>
<td>
<input type="number" step="1" class="prev-input txt-alex inp-alex-<?= $m ?> <?= $inputClass ?>"
style="<?= $isIndicative ? 'color:#64748b;' : '' ?>"
value="<?= $val['amount_alex'] == 0 ? '' : round($val['amount_alex']) ?>"
placeholder="-"
data-target="<?= htmlspecialchars($cat['target']) ?>"
onchange="updateAlloc('<?= $m ?>', <?= $cat['id'] ?>, 'amount_alex', this)">
</td>
<td>
<input type="number" step="1" class="prev-input txt-laia inp-laia-<?= $m ?> <?= $inputClass ?>"
style="<?= $isIndicative ? 'color:#64748b;' : '' ?>"
value="<?= $val['amount_laia'] == 0 ? '' : round($val['amount_laia']) ?>"
placeholder="-"
data-target="<?= htmlspecialchars($cat['target']) ?>"
onchange="updateAlloc('<?= $m ?>', <?= $cat['id'] ?>, 'amount_laia', this)">
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div style="margin: 20px 0; background: white; padding: 20px; border-radius: 12px; box-shadow: var(--shadow-sm); border: 1px solid #e2e8f0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1rem; color: #1e293b;">
📝 <?= tr('bud_prev_notes_for') ?> <span style="text-transform:capitalize;"><?= getTranslatedMonthName($focusDate) ?></span>
</h3>
<span id="note-save-indicator" style="font-size:0.85rem; color:#10b981; font-weight:bold; opacity:0; transition:opacity 0.3s;">
✓ <?= tr('bud_prev_saved') ?>
</span>
</div>
<textarea
id="monthNoteArea"
class="pf-input"
rows="3"
placeholder="<?= tr('bud_prev_notes_ph') ?>"
style="width: 100%; resize: vertical; border-color: #cbd5e1; background: #f8fafc; margin-bottom: 10px;"
><?= htmlspecialchars((string)$currentNote) ?></textarea>
<div style="text-align: right;">
<button type="button" class="pf-btn" style="width: auto; display: inline-flex;"
onclick="saveGenericNote('budget_prev', '<?= $focusDate ?>', document.getElementById('monthNoteArea').value)">
<?= tr('bud_prev_save_note') ?>
</button>
</div>
</div>
<?php
$focusMonth = $months[0];
$targetsOrder = ['vers commune', 'vers L.Pol', 'vers L.Pep', 'vers L.Perso'];
$allTargets = $targetsOrder;
foreach($cats as $c) {
$t = trim($c['target']);
if(!empty($t) && !in_array($t, $allTargets)) {
$allTargets[] = $t;
}
}
$allTargets = array_unique($allTargets);
$summaryData = [];
foreach($allTargets as $t) $summaryData[$t] = ['Alex' => 0, 'Laia' => 0];
foreach ($cats as $cat) {
$t = trim($cat['target']);
if (empty($t)) continue;
$val = $allocs[$focusMonth][$cat['id']] ?? ['amount_alex'=>0, 'amount_laia'=>0];
if (!isset($summaryData[$t])) {
$summaryData[$t] = ['Alex' => 0, 'Laia' => 0];
}
$summaryData[$t]['Alex'] += $val['amount_alex'];
$summaryData[$t]['Laia'] += $val['amount_laia'];
}
$grandTotalAlex = 0;
$grandTotalLaia = 0;
$grandTotalGlobal = 0;
?>
<div class="recap-wrapper">
<div class="recap-card">
<div class="recap-header">
<?= tr('bud_prev_transfers_to_make') ?> - <span style="text-transform:capitalize;"><?= getTranslatedMonthName($focusMonth) ?></span>
</div>
<table class="recap-table">
<thead>
<tr>
<th style="text-align:left;"><?= tr('bud_prev_destination') ?></th>
<th class="col-alex">
<div style="display:flex; flex-direction:column; align-items:center; gap:5px;">
<span>ALEX</span>
<?php if($isValidatedAlex): ?>
<div style="background:#10b981; color:white; padding:4px 8px; border-radius:4px; font-size:0.7rem; display:flex; align-items:center; gap:4px;">
✓ <?= tr('bud_prev_done') ?>
</div>
<?php else: ?>
<button onclick="validateTransfers('Alex', '<?= $focusMonth ?>')" class="pf-btn btn-small" style="background:white; color:#0891b2; border:1px solid #0891b2; font-size:0.7rem; padding:2px 8px; height:auto;">
<?= tr('bud_prev_validate') ?>
</button>
<?php endif; ?>
</div>
</th>
<th class="col-laia">
<div style="display:flex; flex-direction:column; align-items:center; gap:5px;">
<span>LAIA</span>
<?php if($isValidatedLaia): ?>
<div style="background:#10b981; color:white; padding:4px 8px; border-radius:4px; font-size:0.7rem; display:flex; align-items:center; gap:4px;">
✓ <?= tr('bud_prev_done') ?>
</div>
<?php else: ?>
<button onclick="validateTransfers('Laia', '<?= $focusMonth ?>')" class="pf-btn btn-small" style="background:white; color:#d97706; border:1px solid #d97706; font-size:0.7rem; padding:2px 8px; height:auto;">
<?= tr('bud_prev_validate') ?>
</button>
<?php endif; ?>
</div>
</th>
<th class="col-global">Global</th>
</tr>
</thead>
<tbody>
<?php foreach($allTargets as $target):
$tId = md5($target);
?>
<tr id="row_summary_<?= $tId ?>">
<td><?= htmlspecialchars($target) ?></td>
<td class="col-alex sum-target" id="sum_alex_<?= $tId ?>">0 €</td>
<td class="col-laia sum-target" id="sum_laia_<?= $tId ?>">0 €</td>
<td class="col-global sum-target" id="sum_global_<?= $tId ?>">0 €</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="row-grand-total">
<td><?= tr('bud_prev_grand_total') ?></td>
<td class="col-alex sum-target" id="grand_total_alex">0 €</td>
<td class="col-laia sum-target" id="grand_total_laia">0 €</td>
<td class="col-global sum-target" id="grand_total_global">0 €</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div id="addCatModal" class="pf-modal">
<div class="pf-modal-content" style="background:white; width:95%; max-width:400px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 class="pf-modal-title" style="margin:0; border:none; font-size:1.2rem;"><?= tr('bud_prev_new_line_title') ?></h3>
<button onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted);">&times;</button>
</div>
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
<input type="hidden" name="action" value="add_category">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_prev_label_name') ?></label>
<input type="text" name="name" class="pf-input" required>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_prev_label_target') ?></label>
<select name="target" class="pf-input" required>
<option value="" disabled selected>-- <?= tr('bud_prev_choose') ?> --</option>
<option value="vers L.Pol">vers L.Pol</option>
<option value="vers L.Pep">vers L.Pep</option>
<option value="vers L.Perso">vers L.Perso</option>
<option value="vers commune">vers commune</option>
</select>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label class="pf-label" style="color:#8b5cf6;">🌴 <?= tr('bud_prev_link_holiday') ?></label>
<select name="holiday_id" class="pf-input" style="border-color:#8b5cf6; background:#f5f3ff;">
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
<?php foreach ($activeHolidays as $hol): ?>
<option value="<?= $hol['id'] ?>"><?= htmlspecialchars($hol['title']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer">
<button type="button" onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('bud_add_title') ?></button>
</div>
</form>
</div>
</div>
<div id="editCatModal" class="pf-modal">
<div class="pf-modal-content" style="max-width:400px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 class="pf-modal-title" style="margin:0; border:none; font-size:1.2rem;"><?= tr('bud_prev_edit_line_title') ?></h3>
<button onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted);">&times;</button>
</div>
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
<input type="hidden" name="action" value="update_category">
<input type="hidden" name="cat_id" id="edit_cat_id">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_label_name') ?></label>
<input type="text" name="name" id="edit_cat_name" class="pf-input" required>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_prev_label_target') ?></label>
<select name="target" id="edit_cat_target" class="pf-input" required>
<option value="vers L.Pol">vers L.Pol</option>
<option value="vers L.Pep">vers L.Pep</option>
<option value="vers L.Perso">vers L.Perso</option>
<option value="vers commune">vers commune</option>
</select>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label class="pf-label" style="color:#8b5cf6;">🌴 <?= tr('bud_prev_link_holiday') ?></label>
<select name="holiday_id" id="edit_cat_holiday" class="pf-input" style="border-color:#8b5cf6; background:#f5f3ff;">
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
<?php foreach ($activeHolidays as $hol): ?>
<option value="<?= $hol['id'] ?>"><?= htmlspecialchars($hol['title']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer">
<button type="button" onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
<button id="fabSumMode" class="pf-fab-sum" onclick="toggleSumMode()" title="<?= tr('bud_sav_sum_mode_title') ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="8" y1="12" x2="16" y2="12"></line><line x1="12" y1="8" x2="12" y2="16"></line></svg>
</button>
<div id="sumResultBar" class="pf-sum-bar">
<span class="pf-sum-label"><?= tr('bud_sav_selection') ?></span>
<span id="sumResultValue" class="pf-sum-value">0 €</span>
<button onclick="toggleSumMode()" class="pf-sum-close" title="<?= tr('btn_close') ?>">&times;</button>
</div>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'bud_prev_label_name': <?= json_encode(tr('bud_prev_label_name')) ?>,
'bud_prev_err_no_history': <?= json_encode(tr('bud_prev_err_no_history')) ?>,
'bud_prev_confirm_copy': <?= json_encode(tr('bud_prev_confirm_copy')) ?>,
'bud_prev_confirm_transfers': <?= json_encode(tr('bud_prev_confirm_transfers')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>
};
const currentYear = <?= $currentYear ?? 'new Date().getFullYear()' ?>;
const months = <?= json_encode($months ?? []) ?>;
function openEditModal(btn) {
const id = btn.getAttribute('data-id');
const name = btn.getAttribute('data-name');
const target = btn.getAttribute('data-target');
const holiday = btn.getAttribute('data-holiday');
document.getElementById('edit_cat_id').value = id;
document.getElementById('edit_cat_name').value = name;
document.getElementById('edit_cat_target').value = target;
document.getElementById('edit_cat_holiday').value = holiday;
document.getElementById('editCatModal').style.display = 'flex';
document.body.classList.add('no-scroll');
}
function updateSalary(person, input) {
const row = input.closest('tr');
const salary = parseFloat(row.querySelector('[data-field="salary"]').value) || 0;
const mens = parseFloat(row.querySelector('[data-field="mensualite"]').value) || 0;
const frais = parseFloat(row.querySelector('[data-field="frais_func"]').value) || 0;
const ecoP = parseFloat(row.querySelector('[data-field="eco_perso"]').value) || 0;
const ecoF = parseFloat(row.querySelector('[data-field="eco_family"]').value) || 0;
const restant = salary - (mens + frais + ecoP + ecoF);
document.getElementById('restant_' + person).innerText = Math.round(restant).toLocaleString(window.appLang) + ' €';
saveData('update_salary_config', { year: currentYear, person: person, field: input.dataset.field, value: input.value });
recalcAllAllocations();
}
function updateAlloc(month, catId, personField, input) {
saveData('update_allocation', { month_date: month, cat_id: catId, person: personField, value: input.value || 0 });
recalcAllAllocations();
}
function duplicateMonth() {
const targetDateStr = months[0];
const sourceDateStr = months[1];
if (!sourceDateStr) {
alert(window.I18N['bud_prev_err_no_history']);
return;
}
const formatMonth = (d) => {
let str = new Date(d).toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
return str.charAt(0).toUpperCase() + str.slice(1);
};
const sourceName = formatMonth(sourceDateStr);
const targetName = formatMonth(targetDateStr);
const message = window.I18N['bud_prev_confirm_copy'].replace('%s', sourceName).replace('%t', targetName);
if(!confirm(message)) return;
document.querySelectorAll('.inp-alex-' + sourceDateStr).forEach(sourceInput => {
const catIdMatch = sourceInput.getAttribute('onchange').match(/, (\d+),/);
if(!catIdMatch) return;
const catId = catIdMatch[1];
const row = sourceInput.closest('tr');
const valAlex = sourceInput.value;
const targetAlex = row.querySelector('.inp-alex-' + targetDateStr);
if(targetAlex) {
targetAlex.value = valAlex;
updateAlloc(targetDateStr, catId, 'amount_alex', targetAlex);
}
const valLaia = row.querySelector('.inp-laia-' + sourceDateStr).value;
const targetLaia = row.querySelector('.inp-laia-' + targetDateStr);
if(targetLaia) {
targetLaia.value = valLaia;
updateAlloc(targetDateStr, catId, 'amount_laia', targetLaia);
}
});
}
function recalcAllAllocations() {
const budgetAlex = parseFloat(document.getElementById('eco_family_Alex').value) || 0;
const budgetLaia = parseFloat(document.getElementById('eco_family_Laia').value) || 0;
months.forEach(m => {
let sumAlex = 0;
let sumLaia = 0;
document.querySelectorAll('.inp-alex-' + m).forEach(inp => {
const val = parseFloat(inp.value) || 0;
if (!inp.classList.contains('ignore-calc')) sumAlex += val;
const row = inp.closest('tr');
const laiaVal = parseFloat(row.querySelector('.inp-laia-' + m).value) || 0;
const globalSum = val + laiaVal;
const onchangeStr = inp.getAttribute('onchange');
const matches = onchangeStr.match(/, (\d+),/);
if(matches && matches[1]) {
const gCell = document.getElementById('g_' + m + '_' + matches[1]);
if(gCell) gCell.innerText = globalSum > 0 ? Math.round(globalSum) : '-';
}
});
document.querySelectorAll('.inp-laia-' + m).forEach(inp => {
const val = parseFloat(inp.value) || 0;
if (!inp.classList.contains('ignore-calc')) sumLaia += val;
});
document.getElementById('total_alex_' + m).innerText = Math.round(sumAlex) + ' €';
document.getElementById('total_laia_' + m).innerText = Math.round(sumLaia) + ' €';
document.getElementById('total_global_' + m).innerText = Math.round(sumAlex + sumLaia) + ' €';
const restAlex = budgetAlex - sumAlex;
const restLaia = budgetLaia - sumLaia;
const elRestAlex = document.getElementById('restant_alloc_alex_' + m);
const elRestLaia = document.getElementById('restant_alloc_laia_' + m);
elRestAlex.innerText = Math.round(restAlex) + ' €';
elRestAlex.className = 'val-' + (restAlex >= 0 ? 'ok' : 'ko') + ' sum-target';
elRestLaia.innerText = Math.round(restLaia) + ' €';
elRestLaia.className = 'val-' + (restLaia >= 0 ? 'ok' : 'ko') + ' sum-target';
});
updateSummaryTable();
if(isSumModeActive) updateSumResult();
}
function updateSummaryTable() {
const focusMonth = months[0];
let grandTotalAlex = 0;
let grandTotalLaia = 0;
const dataByTarget = {};
document.querySelectorAll('.inp-alex-' + focusMonth).forEach(inp => {
const target = inp.getAttribute('data-target');
if(target) {
if(!dataByTarget[target]) dataByTarget[target] = { alex: 0, laia: 0 };
dataByTarget[target].alex += (parseFloat(inp.value) || 0);
}
});
document.querySelectorAll('.inp-laia-' + focusMonth).forEach(inp => {
const target = inp.getAttribute('data-target');
if(target) {
if(!dataByTarget[target]) dataByTarget[target] = { alex: 0, laia: 0 };
dataByTarget[target].laia += (parseFloat(inp.value) || 0);
}
});
const tbody = document.querySelector('.recap-table tbody');
if(tbody) {
Array.from(tbody.rows).forEach(row => {
const targetName = row.cells[0].innerText.trim();
const alexSum = dataByTarget[targetName] ? dataByTarget[targetName].alex : 0;
const laiaSum = dataByTarget[targetName] ? dataByTarget[targetName].laia : 0;
const globalSum = alexSum + laiaSum;
row.cells[1].innerText = Math.round(alexSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
row.cells[2].innerText = Math.round(laiaSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
row.cells[3].innerText = Math.round(globalSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
grandTotalAlex += alexSum;
grandTotalLaia += laiaSum;
});
}
document.getElementById('grand_total_alex').innerText = Math.round(grandTotalAlex).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
document.getElementById('grand_total_laia').innerText = Math.round(grandTotalLaia).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
document.getElementById('grand_total_global').innerText = Math.round(grandTotalAlex + grandTotalLaia).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
}
function saveData(action, data) {
const formData = new FormData();
formData.append('action', action);
for (const key in data) formData.append(key, data[key]);
fetch('modules/budget/includes/api/save-budget.php', { method: 'POST', body: formData });
}
function validateTransfers(person, month) {
const msg = window.I18N['bud_prev_confirm_transfers'].replace('%p', person).replace('%m', month);
if (!confirm(msg)) return;
const formData = new FormData();
formData.append('action', 'validate_transfers');
formData.append('person', person);
formData.append('month_date', month);
fetch('modules/budget/includes/api/save-budget.php', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if(data.success) window.location.reload();
else alert("Erreur: " + data.error);
})
.catch(e => alert(window.I18N['bud_err_tech']));
}
function saveGenericNote(noteType, refId, content) {
const formData = new FormData();
formData.append('action', 'save_note');
formData.append('note_type', noteType);
formData.append('reference_id', refId);
formData.append('content', content);
fetch('modules/budget/includes/api/save-budget.php', {
method: 'POST',
body: formData
})
.then(async r => {
const text = await r.text();
if (!r.ok) throw new Error(`Erreur HTTP ${r.status} : ${text}`);
if (!text) throw new Error("Le serveur a renvoyé une réponse vide.");
try {
return JSON.parse(text);
} catch(e) {
throw new Error("Réponse inattendue (non JSON) : " + text);
}
})
.then(data => {
if(data.success) {
const indicator = document.getElementById('note-save-indicator');
if(indicator) {
indicator.style.opacity = '1';
setTimeout(() => indicator.style.opacity = '0', 2000);
}
} else {
alert("Erreur lors de la sauvegarde : " + data.error);
}
})
.catch(e => {
console.error('Erreur technique:', e);
alert(e.message);
});
}
let isSumModeActive = false;
let selectedElementsForSum = new Set();
function toggleSumMode() {
isSumModeActive = !isSumModeActive;
const fab = document.getElementById('fabSumMode');
const resultBar = document.getElementById('sumResultBar');
if (isSumModeActive) {
fab.classList.add('active');
document.body.classList.add('sum-mode-active');
resultBar.classList.add('visible');
updateSumResult();
} else {
fab.classList.remove('active');
document.body.classList.remove('sum-mode-active');
resultBar.classList.remove('visible');
selectedElementsForSum.forEach(el => el.classList.remove('sum-selected'));
selectedElementsForSum.clear();
}
}
function extractNumberFromText(text) {
if (!text) return 0;
const cleanText = text.replace(',', '.').replace(/[^\d.-]/g, '');
return parseFloat(cleanText) || 0;
}
function updateSumResult() {
let total = 0;
selectedElementsForSum.forEach(el => {
let val = 0;
if (el.tagName === 'INPUT') {
val = parseFloat(el.value) || 0;
} else {
val = extractNumberFromText(el.innerText);
}
total += val;
});
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
}
async function deleteCategory(id) {
if (!confirm(tr('bud_prev_confirm_del_line'))) return;
const formData = new FormData();
formData.append('action', 'delete_category');
formData.append('id', id);
formData.append('ajax', '1'); //
try {
const result = await pachaFetch('modules/budget/includes/api/save-budget.php', {
method: 'POST',
body: formData
});
if (result.success) {
window.location.reload();
}
} catch(e) { console.error(e); }
}
document.addEventListener('click', function(e) {
if (!isSumModeActive) return;
const targetElement = e.target.closest('input[type="number"], .sum-target');
if (targetElement) {
e.preventDefault();
if (selectedElementsForSum.has(targetElement)) {
selectedElementsForSum.delete(targetElement);
targetElement.classList.remove('sum-selected');
} else {
selectedElementsForSum.add(targetElement);
targetElement.classList.add('sum-selected');
}
updateSumResult();
}
}, true);
document.addEventListener('DOMContentLoaded', recalcAllAllocations);
// --- INTERCEPTION ASYNCHRONE DES FORMULAIRES DE MODALES ---
document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
try {
submitBtn.disabled = true;
submitBtn.innerText = '⏳ ...';
const formData = new FormData(form);
formData.append('ajax', '1');
const actionUrl = form.getAttribute('action');
// On utilise pachaFetch au lieu de fetch
const result = await pachaFetch(actionUrl, {
method: 'POST',
body: formData
});
if (result.success) {
form.closest('.pf-modal').style.display = 'none';
document.body.classList.remove('no-scroll');
window.location.reload();
} else {
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
}
} catch (error) {
console.error("Erreur Fetch Modale:", error);
alert("Une erreur technique est survenue.");
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalText;
}
});
});
</script>
+553
View File
@@ -0,0 +1,553 @@
<?php
// modules/budget/views/epargne.php
$requestedOwner = $_GET['owner'] ?? 'Nens';
$ownersToDisplay = ($requestedOwner === 'Nens') ? ['Pol', 'Pep'] : [$requestedOwner];
$cycleConfigs = [];
$stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'");
while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) {
$parts = explode('-', $row['reference_id']);
if (count($parts) == 2) {
$mKey = $parts[1] . '-' . $parts[0] . '-01';
$cycleConfigs[$mKey] = json_decode($row['content'], true);
}
}
// Récupération sécurisée du nom des mois (utilise les clés globales existantes)
function getMonthName($dateString) {
$m = date('m', strtotime($dateString));
$y = date('Y', strtotime($dateString));
return tr('month_' . $m) . ' ' . $y;
}
?>
<div class="budget-view">
<div class="view-header">
<div class="owner-tabs">
<a href="?tab=epargne&owner=Alex" class="owner-tab <?= $requestedOwner === 'Alex' ? 'active' : '' ?>">Alex</a>
<a href="?tab=epargne&owner=Laia" class="owner-tab <?= $requestedOwner === 'Laia' ? 'active' : '' ?>">Laia</a>
<a href="?tab=epargne&owner=Nens" class="owner-tab <?= $requestedOwner === 'Nens' ? 'active' : '' ?>">Nens 👶</a>
</div>
</div>
<?php foreach ($ownersToDisplay as $currentOwner):
$stmt = $pdo->prepare("SELECT month_date, category, amount FROM pf_savings WHERE owner = ? ORDER BY month_date DESC");
$stmt->execute([$currentOwner]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = [];
$months = [];
$allCategories = [];
foreach ($rows as $row) {
$m = $row['month_date'];
$cat = $row['category'];
$val = $row['amount'];
$data[$m][$cat] = $val;
if (!in_array($m, $months)) $months[] = $m;
if ($cat !== 'TOTAL_BANQUE' && !in_array($cat, $allCategories)) $allCategories[] = $cat;
}
$months = array_slice($months, 0, 7);
sort($allCategories);
// Définition de la classe couleur selon le propriétaire
$ownerTextClass = '';
if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex';
elseif ($currentOwner === 'Laia') $ownerTextClass = 'txt-laia';
else $ownerTextClass = 'txt-global'; // Pour Pol et Pep
?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'Nens' && $currentOwner !== 'Pol') ? '40px' : '0' ?>;">
<div style="flex-grow: 1;">
<?php if ($requestedOwner === 'Nens'):
$themeClass = 'theme-' . strtolower($currentOwner);
?>
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
<?= $currentOwner ?>
</h3>
<?php endif; ?>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<?php if (!empty($months)): ?>
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= $currentOwner ?>')" class="pf-btn btn-secondary">
🔁 <?= tr('bud_sav_add_one_month') ?>
</button>
<?php endif; ?>
<button onclick="openCustomSavingsModal('<?= $currentOwner ?>')" class="pf-btn">
<?= tr('bud_sav_add_month') ?>
</button>
</div>
</div>
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0;">
<?php if (empty($months)): ?>
<div style="padding: 30px; text-align: center; color: #64748b;">
<p><?= sprintf(tr('bud_sav_no_data'), htmlspecialchars($currentOwner)) ?></p>
</div>
<?php else: ?>
<table class="pf-table savings-table nens-table theme-<?= strtolower($currentOwner) ?>" style="margin-top:0; box-shadow:none; border-radius:16px;">
<thead>
<tr>
<th class="sticky-col" style="background:#f8fafc;"><?= tr('bud_sav_post_month') ?></th>
<?php foreach ($months as $month): ?>
<th>
<div class="month-header-container" style="display: flex; flex-direction: column; align-items: center; gap: 6px;">
<div style="display:flex; flex-direction:column; text-align:center;">
<span class="month-name" style="text-transform:capitalize;"><?= getMonthName($month) ?></span>
<?php
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
echo "<span style='font-size:0.75rem; font-weight:normal; color:#64748b;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</span>";
}
?>
</div>
<div class="month-actions" style="justify-content: center; width: 100%;">
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_edit_modal') ?>"
data-json="<?= htmlspecialchars(json_encode($data[$month] ?? []), ENT_QUOTES, 'UTF-8') ?>"
onclick='editCustomSavingsMonth("<?= $month ?>", "<?= $currentOwner ?>", JSON.parse(this.getAttribute("data-json")))'>
✏️
</button>
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
onclick="deleteEntireMonth('<?= $month ?>', '<?= $currentOwner ?>')"
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
🗑️
</button>
</div>
</div>
</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr class="row-total">
<td class="sticky-col"><strong><?= tr('bud_sav_total_bank') ?></strong></td>
<?php foreach ($months as $month):
$val = $data[$month]['TOTAL_BANQUE'] ?? 0;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input total-input-<?= $currentOwner ?>-<?= $month ?>"
style="width: 70px; font-weight:bold; color:#2563eb;"
value="<?= $val != 0 ? round($val) : '' ?>"
placeholder="0"
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= $currentOwner ?>', this)">
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
</div>
</td>
<?php endforeach; ?>
</tr>
<?php foreach ($allCategories as $cat): ?>
<tr>
<td class="sticky-col"><?= htmlspecialchars($cat) ?></td>
<?php foreach ($months as $month):
$amount = $data[$month][$cat] ?? 0;
?>
<td class="text-center" style="padding:4px;">
<div style="display:flex; align-items:center; justify-content:center; gap:2px;">
<input type="number" step="0.01"
class="prev-input <?= $ownerTextClass ?> cat-input-<?= $currentOwner ?>-<?= $month ?>"
style="width: 70px;"
value="<?= $amount != 0 ? round($amount) : '' ?>"
placeholder="-"
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= $currentOwner ?>', this)">
</div>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<tr class="row-extres">
<td class="sticky-col"><strong><?= tr('bud_sav_extra') ?></strong></td>
<?php foreach ($months as $month):
$total = $data[$month]['TOTAL_BANQUE'] ?? 0;
$sum = 0;
foreach ($allCategories as $cat) $sum += ($data[$month][$cat] ?? 0);
$extra = $total - $sum;
?>
<td class="text-center font-bold sum-target" id="extra_<?= $currentOwner ?>_<?= $month ?>" style="color: <?= $extra >= 0 ? '#10b981' : '#ef4444' ?>; padding:12px;">
<?= number_format($extra, 0, ',', ' ') ?> €
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div id="savingsModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width: 95%;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="savingsModalTitle" class="pf-modal-title" style="margin:0;"><?= tr('bud_sav_modal_title_add') ?></h3>
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button>
</div>
<form action="modules/budget/includes/api/save-savings.php" method="POST" id="savingsForm">
<input type="hidden" name="owner" id="sav_owner">
<input type="hidden" name="redirect_tab" id="redirect_tab" value="<?= htmlspecialchars($requestedOwner) ?>">
<input type="hidden" name="month_date" id="sav_date_hidden">
<div style="display:flex; gap:15px; margin-bottom:20px;">
<div class="form-group" style="flex:1; margin:0;">
<label class="pf-label"><?= tr('bud_sav_month_concerned') ?></label>
<input type="month" id="sav_month" required class="pf-input">
</div>
<div class="form-group" style="flex:1; margin:0;">
<label class="pf-label"><?= tr('bud_sav_total_bank_eur') ?></label>
<input type="number" step="0.01" name="values[TOTAL_BANQUE]" id="sav_total" required class="pf-input no-spinners" style="font-weight:bold; color:#2563eb;">
</div>
</div>
<div class="separator" style="margin: 20px 0; border-bottom: 1px solid #e2e8f0;"></div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<div>
<h4 style="margin:0; font-size:1rem; color:#1e293b;"><?= tr('bud_sav_ventilation') ?></h4>
<span style="font-size:0.8rem; color:#64748b;"><?= tr('bud_sav_adj_help') ?></span>
</div>
<button type="button" class="pf-btn btn-secondary" onclick="addCustomEpargneLine()" style="padding:4px 10px; height:auto; width:auto; font-size:0.9rem;"> <?= tr('bud_sav_add_line') ?></button>
</div>
<div style="display:flex; gap:10px; padding:0 5px 5px 5px; font-size:0.8rem; color:#64748b; font-weight:600;">
<div style="flex:2;"><?= tr('bud_category') ?></div>
<div style="width:100px;"><?= tr('bud_sav_current') ?></div>
<div style="width:90px;"><?= tr('bud_sav_adjust') ?></div>
<div style="width:100px;"><?= tr('bud_sav_new') ?></div>
<div style="width:28px;"></div>
</div>
<div id="linesContainer" style="max-height: 350px; overflow-y: auto; padding-right:5px; display:flex; flex-direction:column; gap:10px;">
</div>
<div style="margin-top:25px; display:flex; justify-content:flex-end; gap:10px;">
<button type="button" onclick="document.getElementById('savingsModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary" style="width:auto; margin:0;"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" style="width:auto; margin:0;"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
<button id="fabSumMode" class="pf-fab-sum" onclick="toggleSumMode()" title="<?= tr('bud_sav_sum_mode_title') ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="8" y1="12" x2="16" y2="12"></line><line x1="12" y1="8" x2="12" y2="16"></line></svg>
</button>
<div id="sumResultBar" class="pf-sum-bar">
<span class="pf-sum-label"><?= tr('bud_sav_selection') ?></span>
<span id="sumResultValue" class="pf-sum-value">0 €</span>
<button onclick="toggleSumMode()" class="pf-sum-close" title="<?= tr('btn_close') ?>">&times;</button>
</div>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'bud_sav_modal_title_add': <?= json_encode(tr('bud_sav_modal_title_add')) ?>,
'bud_sav_modal_title_edit': <?= json_encode(tr('bud_sav_modal_title_edit')) ?>,
'bud_sav_ph_name': <?= json_encode(tr('bud_sav_ph_name')) ?>,
'bud_sav_confirm_delete_month': <?= json_encode(tr('bud_sav_confirm_delete_month')) ?>,
'bud_sav_prompt_duplicate': <?= json_encode(tr('bud_sav_prompt_duplicate')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_err_server': <?= json_encode(tr('bud_err_server')) ?>,
'bud_err_network_dup': <?= json_encode(tr('bud_err_network_dup')) ?>,
'bud_sav_saving': <?= json_encode(tr('bud_sav_saving')) ?>,
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>
};
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT ---
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
function updateEpargneCell(month, category, owner, inputEl) {
const val = parseFloat(inputEl.value) || 0;
const formData = new FormData();
formData.append('action', 'update_single_entry');
formData.append('month_date', month);
formData.append('category', category);
formData.append('owner', owner);
formData.append('amount', val);
fetch('modules/budget/includes/api/save-savings.php', {
method: 'POST',
body: formData
}).catch(err => alert(window.I18N['bud_err_tech'] || 'Erreur technique'));
const totalInput = document.querySelector(`.total-input-${owner}-${month}`);
const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
let sumCats = 0;
document.querySelectorAll(`.cat-input-${owner}-${month}`).forEach(inp => {
sumCats += parseFloat(inp.value) || 0;
});
const extra = totalVal - sumCats;
const extraCell = document.getElementById(`extra_${owner}_${month}`);
if (extraCell) {
extraCell.innerText = Math.round(extra).toLocaleString(window.appLang) + ' €';
extraCell.style.color = extra >= 0 ? '#10b981' : '#ef4444';
}
if(isSumModeActive) updateSumResult();
}
function addCustomEpargneLine(catName = '', amount = '') {
const container = document.getElementById('linesContainer');
const baseAmount = (amount !== '' && amount !== null) ? parseFloat(amount).toFixed(2) : '0.00';
const inputName = catName ? `values[${catName}]` : '';
const html = `
<div class="ventilation-line" style="display:flex; gap:10px; align-items:center; background:#f8fafc; padding:8px; border-radius:8px; border:1px solid #e2e8f0;">
<div style="flex:2;">
<input type="text" class="pf-input cat-name-input" value="${catName}" placeholder="${window.I18N['bud_sav_ph_name'] || 'Catégorie'}" oninput="updateCustomFieldName(this)" style="padding:6px; font-size:0.9rem;" required>
</div>
<div style="width:100px;">
<input type="number" step="0.01" class="pf-input base-amount no-spinners" value="${baseAmount}" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; background:#fff;">
</div>
<div style="width:90px;">
<input type="number" step="0.01" class="pf-input adjustment-amount no-spinners" placeholder="+ / -" oninput="recalculateCustomLine(this)" style="padding:6px; font-size:0.9rem; color:#f59e0b; font-weight:bold;">
</div>
<div style="width:100px;">
<input type="number" step="0.01" name="${inputName}" class="pf-input final-amount no-spinners" value="${baseAmount}" style="padding:6px; font-size:0.9rem; font-weight:bold; background:#e0f2fe; border-color:#bae6fd; color:#0369a1;" readonly>
</div>
<button type="button" onclick="this.parentElement.remove()" style="width:28px; height:28px; border:none; background:#fee2e2; color:#ef4444; border-radius:4px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-weight:bold;">&times;</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
function updateCustomFieldName(inputElement) {
const line = inputElement.closest('.ventilation-line');
const finalInput = line.querySelector('.final-amount');
const newName = inputElement.value.trim();
if (newName) finalInput.name = `values[${newName}]`;
else finalInput.name = '';
}
function recalculateCustomLine(inputElement) {
const line = inputElement.closest('.ventilation-line');
const baseInput = line.querySelector('.base-amount');
const adjInput = line.querySelector('.adjustment-amount');
const finalInput = line.querySelector('.final-amount');
const base = parseFloat(baseInput.value) || 0;
const adj = parseFloat(adjInput.value) || 0;
finalInput.value = (base + adj).toFixed(2);
}
function editCustomSavingsMonth(monthDate, owner, rowData) {
document.getElementById('sav_owner').value = owner;
const ym = monthDate.substring(0, 7);
document.getElementById('sav_month').value = ym;
const dateObj = new Date(monthDate);
const monthName = dateObj.toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_edit'] || 'Editer') + " " + monthName + " (" + owner + ")";
document.getElementById('sav_total').value = rowData['TOTAL_BANQUE'] || '';
const container = document.getElementById('linesContainer');
container.innerHTML = '';
for (const [cat, val] of Object.entries(rowData)) {
if (cat !== 'TOTAL_BANQUE') addCustomEpargneLine(cat, val);
}
if (container.children.length === 0) addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex';
document.body.classList.add('no-scroll');
}
function openCustomSavingsModal(owner) {
document.getElementById('sav_owner').value = owner;
document.getElementById('sav_month').value = '';
document.getElementById('sav_total').value = '';
document.getElementById('savingsModalTitle').innerText = (window.I18N['bud_sav_modal_title_add'] || 'Ajouter') + " (" + owner + ")";
const container = document.getElementById('linesContainer');
container.innerHTML = '';
addCustomEpargneLine();
document.getElementById('savingsModal').style.display = 'flex';
document.body.classList.add('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('savingsModal');
if (event.target == modal) {
modal.style.display = 'none';
document.body.classList.remove('no-scroll');
}
}
const savingsForm = document.getElementById('savingsForm');
if (savingsForm) {
savingsForm.addEventListener('submit', function(e) {
e.preventDefault();
const ym = document.getElementById('sav_month').value;
if(ym) {
document.getElementById('sav_date_hidden').value = ym + '-01';
}
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = window.I18N['bud_sav_saving'] || 'Sauvegarde...';
submitBtn.disabled = true;
const formData = new FormData(this);
const actionUrl = this.getAttribute('action');
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
fetch(finalUrl, { method: 'POST', body: formData })
.then(response => response.text())
.then(text => { window.location.reload(); })
.catch(error => {
console.error("Erreur:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
submitBtn.innerText = originalText;
submitBtn.disabled = false;
});
});
}
function deleteEntireMonth(monthDate, owner) {
const rawMsg = window.I18N['bud_sav_confirm_delete_month'] || "Supprimer %m pour %o ?";
const msg = rawMsg.replace('%m', monthDate).replace('%o', owner);
if (!confirm(msg)) return;
const formData = new FormData();
formData.append("action", "delete_month_global");
formData.append("month_date", monthDate);
formData.append("owner", owner);
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(() => window.location.reload())
.catch(err => alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression'));
}
function duplicateLastMonth(lastMonthDate, owner) {
let dateObj = new Date(lastMonthDate);
dateObj.setMonth(dateObj.getMonth() + 1);
let year = dateObj.getFullYear();
let month = String(dateObj.getMonth() + 1).padStart(2, '0');
let nextMonthStr = `${year}-${month}-01`;
const formatMonth = (d) => {
let str = new Date(d).toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
return str.charAt(0).toUpperCase() + str.slice(1);
};
const sourceName = formatMonth(lastMonthDate);
const targetName = formatMonth(nextMonthStr);
let defaultTotal = "";
if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) {
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
}
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer %s vers %t1 ?";
const message = rawMsg.replace('%s', sourceName).replace('%t1', targetName).replace('%t2', targetName);
let newTotal = prompt(message, defaultTotal);
if (newTotal !== null && newTotal.trim() !== "") {
const formData = new FormData();
formData.append("action", "duplicate_month");
formData.append("source_date", lastMonthDate);
formData.append("target_date", nextMonthStr);
formData.append("new_total", newTotal);
formData.append("owner", owner);
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
.then(async r => {
const text = await r.text();
try {
const d = JSON.parse(text);
if (d.success) window.location.reload();
else alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (d.error || "Inconnue"));
} catch(e) {
window.location.reload();
}
})
.catch(err => {
console.error(err);
alert(window.I18N['bud_err_network_dup'] || 'Erreur réseau.');
});
}
}
// --- CALCULATRICE ---
let isSumModeActive = false;
let selectedElementsForSum = new Set();
function toggleSumMode() {
isSumModeActive = !isSumModeActive;
const fab = document.getElementById('fabSumMode');
const resultBar = document.getElementById('sumResultBar');
if (isSumModeActive) {
fab.classList.add('active');
document.body.classList.add('sum-mode-active');
resultBar.classList.add('visible');
updateSumResult();
} else {
fab.classList.remove('active');
document.body.classList.remove('sum-mode-active');
resultBar.classList.remove('visible');
selectedElementsForSum.forEach(el => el.classList.remove('sum-selected'));
selectedElementsForSum.clear();
}
}
function extractNumberFromText(text) {
if (!text) return 0;
const cleanText = text.replace(',', '.').replace(/[^\d.-]/g, '');
return parseFloat(cleanText) || 0;
}
function updateSumResult() {
let total = 0;
selectedElementsForSum.forEach(el => {
let val = 0;
if (el.tagName === 'INPUT') {
val = parseFloat(el.value) || 0;
} else {
val = extractNumberFromText(el.innerText);
}
total += val;
});
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
}
document.addEventListener('click', function(e) {
if (!isSumModeActive) return;
const targetElement = e.target.closest('input[type="number"], .sum-target');
if (targetElement) {
e.preventDefault();
if (selectedElementsForSum.has(targetElement)) {
selectedElementsForSum.delete(targetElement);
targetElement.classList.remove('sum-selected');
} else {
selectedElementsForSum.add(targetElement);
targetElement.classList.add('sum-selected');
}
updateSumResult();
}
}, true);
</script>
+434
View File
@@ -0,0 +1,434 @@
<?php
// modules/budget/views/recap.php
// 1. Récupération des Items du Budget
$stmt = $pdo->query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC");
$items = $stmt->fetchAll();
// 2. Déterminer quel est le mois de gestion "ouvert" par défaut
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
$defaultActiveMonth = $stmtActive->fetchColumn();
if (!$defaultActiveMonth) {
$defaultActiveMonth = date('Y-m-01');
}
// 3. Assigner le mois et l'année en fonction du mois ouvert
$currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth));
$viewMonthDate = "$currentYear-$currentMonth-01";
$sqlReal = "SELECT budget_item_id, SUM(amount) as total_real
FROM pf_expenses
WHERE gestion_month = ? AND budget_item_id IS NOT NULL
GROUP BY budget_item_id";
$stmtReal = $pdo->prepare($sqlReal);
$stmtReal->execute([$viewMonthDate]);
$realTotals = $stmtReal->fetchAll(PDO::FETCH_KEY_PAIR); // Retourne un tableau [id => total]
$sqlCatReal = "SELECT category, SUM(amount) as total_real
FROM pf_expenses
WHERE gestion_month = ? AND budget_item_id IS NULL
GROUP BY category";
$stmtCatReal = $pdo->prepare($sqlCatReal);
$stmtCatReal->execute([$viewMonthDate]);
$catTotals = $stmtCatReal->fetchAll(PDO::FETCH_KEY_PAIR);
$stmtLabels = $pdo->prepare("SELECT label, amount FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL");
$stmtLabels->execute([$viewMonthDate]);
$unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC);
$moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
$monthTranslationKey = 'month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT);
$currentMonthName = tr($monthTranslationKey) . ' ' . $currentYear;
$totalDepenses = 0;
$totalRevenus = 0;
?>
<div class="budget-view">
<div class="view-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2 style="margin:0;"><?= tr('bud_recap_monthly_title') ?></h2>
<button onclick="openRecapModal('add')" class="pf-btn"> <?= tr('bud_recap_btn_add') ?></button>
</div>
<div class="table-responsive" style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0; overflow:hidden;">
<table class="pf-table" style="margin:0; box-shadow:none;">
<thead style="background:#f8fafc;">
<tr>
<th><?= tr('bud_label_name') ?></th>
<th><?= tr('bud_planned_amount') ?></th>
<th><?= tr('bud_type') ?></th>
<th><?= tr('bud_day') ?></th>
<th><?= tr('bud_month_state') ?> (<?= $currentMonthName ?>)</th>
<th style="text-align:right;"><?= tr('actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item):
// --- 1. CALCUL DES TOTAUX PRÉVUS ---
$targetAbs = abs((float)$item['amount']);
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
if ($item['category'] === 'income') $totalRevenus += $amountToAdd;
else $totalDepenses += $amountToAdd;
// --- 2. CALCUL DU RÉEL (Logique optimisée) ---
$realSum = 0;
$hasMatchingExpense = false;
// A. Correspondance directe par ID
if (isset($realTotals[$item['id']])) {
$realSum = $realTotals[$item['id']];
$hasMatchingExpense = true;
}
// B. Correspondance par catégorie système (École, Essence, FMCG)
else {
$catKey = null;
if (trim($item['name']) === 'Estimacio escola') $catKey = 'School';
elseif (trim($item['name']) === 'Estimation gasolina') $catKey = 'Essence';
elseif (trim($item['name']) === 'Estimacio F&B & beauty') $catKey = 'FMCG';
if ($catKey && isset($catTotals[$catKey])) {
$realSum = $catTotals[$catKey];
$hasMatchingExpense = true;
}
// C. Correspondance par mots-clés (seulement sur les dépenses non liées)
elseif (!empty($item['mapping_keywords'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($unlinkedExpenses as $uexp) {
foreach ($keywords as $kw) {
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
$realSum += (float)$uexp['amount'];
$hasMatchingExpense = true;
break;
}
}
}
}
}
$realAbs = abs($realSum);
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
if ($item['is_estimate']) $rowClass .= ' row-estimate';
?>
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid var(--border-light);">
<td style="padding:15px;">
<strong><?= htmlspecialchars($item['name']) ?></strong>
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">('.tr('bud_est_short').')</small>' : '' ?>
<?php if(!empty($item['mapping_keywords'])): ?>
<span title="<?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
<?php endif; ?>
<?php if(!empty($item['reg_month'])): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-style:italic; margin-top:2px;">
📅 <?= sprintf(tr('bud_reg_planned_in'), tr('month_'.str_pad(array_search($item['reg_month'], $moisFr), 2, '0', STR_PAD_LEFT))) ?>
</div>
<?php endif; ?>
</td>
<td class="cell-amount" style="font-weight:600; padding:15px; color:<?= $item['category']==='income'?'#10b981':'#1e293b' ?>;">
<?= number_format($targetAbs, 2, ',', ' ') ?> €
<?php if ($hasMatchingExpense): ?>
<?php
// Le "Gap" est la différence visuelle.
$gap = $realAbs - $targetAbs;
if ($gap > 0.05): ?>
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">
<?= $item['category'] === 'income' ? tr('bud_bonus') : tr('bud_overrun') ?> : +<?= number_format($gap, 2, ',', ' ') ?> €
</div>
<?php elseif ($gap < -0.05): ?>
<div style="font-size:0.75rem; color:#f59e0b; font-weight:normal;">
<?= tr('bud_remaining') ?> : <?= number_format(abs($gap), 2, ',', ' ') ?> €
</div>
<?php else: ?>
<div style="font-size:0.75rem; color:#10b981; font-weight:normal;">
<?= tr('bud_exact_amount') ?> ✓
</div>
<?php endif; ?>
<?php elseif ($item['type'] === 'Annuel'): ?>
<div style="font-size:0.75rem; color:#94a3b8; font-weight:normal;"><?= tr('bud_per_month_short') ?> <?= number_format($amountToAdd, 2, ',', ' ') ?>/<?= tr('bud_month_short') ?></div>
<?php endif; ?>
</td>
<td style="padding:15px;">
<span class="badge-type <?= strtolower($item['type']) ?>" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;">
<?= tr('bud_freq_'.strtolower($item['type'])) ?>
</span>
</td>
<td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ? $item['payment_day'] : '-' ?></td>
<td style="padding:15px;">
<?php if ($isAutoChecked): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;">
<span>✓</span> <?= tr('bud_state_validated') ?>
</div>
<?php elseif($hasMatchingExpense): ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;">
<span>⏳</span> <?= tr('bud_state_partial') ?>
</div>
<?php else: ?>
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;">
<span>○</span> <?= tr('bud_state_waiting') ?>
</div>
<?php endif; ?>
</td>
<td style="padding:15px; text-align:right;">
<div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;">
<button class="btn-icon-action edit" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot style="background:#f8fafc;">
<tr>
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_income_smoothed') ?></strong></td>
<td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
</tr>
<tr>
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_expenses_smoothed') ?></strong></td>
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
</tr>
<tr style="border-top: 2px solid #e2e8f0; background: white;">
<td colspan="1" style="padding:15px; font-size:1.1rem;"><strong><?= tr('bud_theoretical_balance_recap') ?></strong></td>
<?php $balance = $totalRevenus - $totalDepenses; ?>
<td colspan="5" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>">
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / <?= tr('bud_month_short') ?></strong>
</td>
</tr>
</tfoot>
</table>
</div>
<div class="budget-note" style="margin-top:15px; font-size:0.85rem; color:#64748b;">
<p>* <?= tr('bud_recap_footer_note') ?></p>
</div>
</div>
<div id="budgetRecapModal" class="pf-modal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
<div class="pf-modal-content" style="background:white; width:95%; max-width:500px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px; position:relative;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="recapModalTitle" style="margin:0; font-size:1.2rem; color:#1e293b;"><?= tr('bud_recap_modal_add') ?></h3>
<button type="button" onclick="closeRecapModal()" style="border:none; background:none; font-size:1.8rem; cursor:pointer; color:#64748b; line-height:1;">&times;</button>
</div>
<form action="modules/budget/includes/api/manage-item.php" method="POST" id="recapForm">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" id="item_id">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_name') ?></label>
<input type="text" name="name" id="item_name" required class="pf-input" placeholder="<?= tr('bud_ph_item_name') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_keywords') ?></label>
<input type="text" name="mapping_keywords" id="item_keywords" class="pf-input" placeholder="<?= tr('bud_ph_keywords') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:#f0f9ff; border-color:#bae6fd;">
<small style="color:#64748b; font-size:0.75rem;"><?= tr('bud_help_keywords') ?></small>
</div>
<div style="display:flex; gap:15px; margin-bottom:15px;">
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_amount_eur') ?></label>
<input type="number" step="0.01" name="amount" id="item_amount" required class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div>
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_day') ?></label>
<input type="number" min="1" max="31" name="payment_day" id="item_day" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
</div>
</div>
<div style="display:flex; gap:15px; margin-bottom:15px;">
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_category') ?></label>
<select name="category" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="expense"><?= tr('bud_cat_expense') ?></option>
<option value="income"><?= tr('bud_cat_income') ?></option>
</select>
</div>
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_frequency') ?></label>
<select name="type" id="item_type" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="Mensuel"><?= tr('bud_freq_mensuel') ?></option>
<option value="Annuel"><?= tr('bud_freq_annuel') ?></option>
</select>
</div>
</div>
<div style="display:flex; gap:15px; margin-bottom:25px;">
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_amount_type') ?></label>
<select name="is_estimate" id="item_is_estimate" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value="0"><?= tr('bud_type_fixed') ?></option>
<option value="1"><?= tr('bud_type_variable') ?></option>
</select>
</div>
<div style="flex:1;">
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_regularization') ?></label>
<select name="reg_month" id="item_reg_month" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
<option value=""><?= tr('bud_reg_none') ?></option>
<?php
foreach($moisFr as $index => $m) {
if($index == 0) continue;
echo "<option value='$m'>" . tr('month_'.str_pad($index, 2, '0', STR_PAD_LEFT)) . "</option>";
}
?>
</select>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" onclick="closeRecapModal()" class="pf-btn btn-secondary" style="width:auto; margin:0; background:#f1f5f9; color:#475569; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn" style="width:auto; margin:0; background:#2563eb; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
<script>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'bud_recap_modal_add': <?= json_encode(tr('bud_recap_modal_add')) ?>,
'bud_recap_modal_edit': <?= json_encode(tr('bud_recap_modal_edit')) ?>,
'bud_recap_confirm_delete': <?= json_encode(tr('bud_recap_confirm_delete')) ?>,
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'bud_saving': <?= json_encode(tr('bud_sav_saving') ?? 'Sauvegarde...') ?>
};
function openRecapModal(mode) {
if (mode === "add") {
document.getElementById("recapModalTitle").innerText = window.I18N['bud_recap_modal_add'] || 'Ajouter';
document.getElementById("item_id").value = "";
document.getElementById("recapForm").reset();
}
document.getElementById("budgetRecapModal").style.display = "flex";
document.body.classList.add('no-scroll');
}
function closeRecapModal() {
document.getElementById("budgetRecapModal").style.display = "none";
document.body.classList.remove('no-scroll');
}
window.onclick = function(event) {
const modal = document.getElementById('budgetRecapModal');
if (event.target == modal) {
closeRecapModal();
}
}
function editRecapItem(item) {
const data = typeof item === "string" ? JSON.parse(item) : item;
document.getElementById("recapModalTitle").innerText = (window.I18N['bud_recap_modal_edit'] || 'Editer') + " : " + data.name;
document.getElementById("item_id").value = data.id;
document.getElementById("item_name").value = data.name;
document.getElementById("item_keywords").value = data.mapping_keywords || '';
document.getElementById("item_amount").value = Math.abs(data.amount);
document.getElementById("item_category").value = data.category;
document.getElementById("item_type").value = data.type;
document.getElementById("item_day").value = data.payment_day;
document.getElementById("item_reg_month").value = data.reg_month || "";
document.getElementById("item_is_estimate").value = data.is_estimate;
document.getElementById("budgetRecapModal").style.display = "flex";
document.body.classList.add('no-scroll');
}
// --- 2. INTERCEPTION ASYNCHRONE DU FORMULAIRE ---
const recapForm = document.getElementById('recapForm');
if (recapForm) {
recapForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerText;
submitBtn.innerText = window.I18N['bud_saving'] || '⏳ ...';
submitBtn.disabled = true;
const formData = new FormData(this);
formData.append('ajax', '1');
// 💡 Utilisation sécurisée de getAttribute
const actionUrl = this.getAttribute('action');
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
try {
const response = await fetch(finalUrl, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// 💡 Lecture robuste (anti-Warnings PHP)
const textResult = await response.text();
try {
const result = JSON.parse(textResult);
if (result.success) {
closeRecapModal();
window.location.reload();
} else {
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
}
} catch (jsonError) {
console.error("Réponse non-JSON :", textResult);
alert("Le serveur a renvoyé une erreur PHP. Regarde la console (F12).");
}
} catch (error) {
console.error("Erreur Fetch:", error);
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalText;
}
});
}
// --- 3. SUPPRESSION ASYNCHRONE ---
async function deleteRecapItem(id) {
if (!confirm(window.I18N['bud_recap_confirm_delete'] || "Confirmer la suppression ?")) return;
const formData = new FormData();
formData.append("action", "delete");
formData.append("id", id);
formData.append("ajax", "1"); // Signale à l'API qu'on attend du JSON
try {
const response = await fetch("modules/budget/includes/api/manage-item.php", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const textResult = await response.text();
try {
const result = JSON.parse(textResult);
// On s'assure que si l'API ne renvoie pas success, on le signale
if (result.success !== false) {
window.location.reload();
} else {
alert((window.I18N['bud_err_delete'] || 'Erreur de suppression') + " : " + (result.error || ""));
}
} catch (jsonErr) {
console.error("Réponse non-JSON lors de la suppression :", textResult);
window.location.reload(); // Fallback si l'API redirige au lieu de répondre en JSON
}
} catch (err) {
console.error("Erreur réseau Suppression:", err);
alert(window.I18N['bud_err_delete'] || 'Erreur réseau');
}
}
</script>
+886
View File
@@ -0,0 +1,886 @@
<?php
// modules/budget/views/suivi.php
// ============================================================================
// 1. GESTION DU MOIS ACTIF ET DE LA NAVIGATION
// ============================================================================
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
$defaultActiveMonth = $stmtActive->fetchColumn();
if (!$defaultActiveMonth) {
$defaultActiveMonth = date('Y-m-01');
}
$viewM = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
$viewY = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth));
$viewMonthDate = "$viewY-$viewM-01";
$prevDate = date('Y-m-01', strtotime('-1 month', strtotime($viewMonthDate)));
$nextDate = date('Y-m-01', strtotime('+1 month', strtotime($viewMonthDate)));
$prevLink = "?tab=suivi&m=" . date('m', strtotime($prevDate)) . "&y=" . date('Y', strtotime($prevDate));
$nextLink = "?tab=suivi&m=" . date('m', strtotime($nextDate)) . "&y=" . date('Y', strtotime($nextDate));
$defaultLink = "?tab=suivi";
// ============================================================================
// 2. GESTION DES ACTIONS POST (Clôture, Ajouts, CSV)
// ============================================================================
if (isset($_POST['action']) && $_POST['action'] === 'close_month') {
$monthToClose = $_POST['close_month_date'];
$nextMonthToOpen = date('Y-m-01', strtotime('+1 month', strtotime($monthToClose)));
$frozenData = [
'is_closed' => true,
'solde_actuel' => (float)$_POST['freeze_solde_actuel'],
'capacite_max' => (float)$_POST['freeze_capacite_max'],
'solde_theorique' => (float)$_POST['freeze_solde_theorique'],
'reste_a_venir' => (float)$_POST['freeze_reste_a_venir'],
'closed_at' => date('Y-m-d H:i:s')
];
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('month_closure', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
->execute([$monthToClose, json_encode($frozenData)]);
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
->execute([$nextMonthToOpen]);
echo "<script>window.location.href='?tab=suivi&m=" . date('m', strtotime($nextMonthToOpen)) . "&y=" . date('Y', strtotime($nextMonthToOpen)) . "';</script>";
exit;
}
if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
$pdo->prepare("DELETE FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?")->execute([$viewMonthDate]);
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
->execute([$viewMonthDate]);
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY';</script>";
exit;
}
if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
$count = 0;
$stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)");
if (isset($_POST['lines']) && is_array($_POST['lines'])) {
foreach ($_POST['lines'] as $line) {
if (isset($line['import_check'])) {
$cat = $line['cat'];
$is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0;
$budgetItemId = !empty($line['budget_item_id']) ? (int)$line['budget_item_id'] : null;
$holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null;
$gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate;
if ($is_credit && empty($cat)) continue;
if (!$is_credit && empty($cat)) continue;
$finalAmount = $is_credit ? abs($line['amount']) : -abs($line['amount']);
$dateToSave = $line['date'];
try {
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]);
$stmtRule->execute([$line['label'], $cat]);
$count++;
} catch (Exception $e) { continue; }
}
}
}
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY&msg=imported_$count';</script>";
exit;
}
if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') {
$pdo->query("DELETE FROM pf_bank_snapshots");
$pdo->prepare("INSERT INTO pf_bank_snapshots (snapshot_date, amount) VALUES (?, ?)")->execute([$_POST['snapshot_date'], floatval($_POST['snapshot_amount'])]);
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY';</script>";
exit;
}
// ============================================================================
// E-Bis. LECTURE ET PRÉVISUALISATION DU FICHIER CSV
// ============================================================================
$csvData = [];
$showPreview = false;
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
$file = $_FILES['csv_file']['tmp_name'];
$handle = fopen($file, "r");
$rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } catch(Exception $e){}
$existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){}
fgetcsv($handle, 1000, ";", "\"", "\\");
while (($data = fgetcsv($handle, 1000, ";", "\"", "\\")) !== FALSE) {
$rawDebit = $data[8] ?? ''; $rawCredit = $data[9] ?? '';
$amount = 0; $isCredit = 0;
if (!empty(trim($rawCredit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawCredit))); $isCredit = 1; }
elseif (!empty(trim($rawDebit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawDebit))); }
else continue;
$dateParts = explode('/', $data[0]);
$dateSql = (count($dateParts) == 3) ? $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0] : date('Y-m-d');
$label = trim($data[1]) ?: trim($data[2]);
$refCSV = trim($data[3]);
$uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit);
$isDuplicate = in_array($uniqueKey, $existingRefs);
$suggestedCat = '';
foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } }
$csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit];
}
fclose($handle);
$showPreview = true;
}
// ============================================================================
// 3. RECUPERATION DES DONNEES ET CALCULS
// ============================================================================
$stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?");
$stmtCheckClose->execute([$viewMonthDate]);
$closureJson = $stmtCheckClose->fetchColumn();
$monthState = $closureJson ? json_decode($closureJson, true) : null;
$isClosed = ($monthState && isset($monthState['is_closed']) && $monthState['is_closed'] === true);
$snapshot = ['date' => date('Y-m-d'), 'amount' => 0];
$snapStmt = $pdo->query("SELECT * FROM pf_bank_snapshots ORDER BY id DESC LIMIT 1");
if ($s = $snapStmt->fetch(PDO::FETCH_ASSOC)) {
$snapshot = ['date' => $s['snapshot_date'], 'amount' => (float)$s['amount']];
}
$stmtPrevClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?");
$stmtPrevClose->execute([$prevDate]);
$prevClosureJson = $stmtPrevClose->fetchColumn();
$prevMonthState = $prevClosureJson ? json_decode($prevClosureJson, true) : null;
$solde_initial = ($prevMonthState && isset($prevMonthState['solde_actuel'])) ? (float)$prevMonthState['solde_actuel'] : 0;
if ($solde_initial === 0 && !$prevMonthState) {
$solde_initial = $snapshot['amount'];
}
$stmt = $pdo->prepare("SELECT * FROM pf_expenses WHERE gestion_month = ? ORDER BY date_exp DESC");
$stmt->execute([$viewMonthDate]);
$allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC);
$paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id');
$realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label');
$budget_fmcg = 0; $budget_school = 0; $budget_essence = 0; $budget_frais = 0; $budget_income_prevu = 0;
$total_income = 0; $total_expenses_prevues = 0;
$reste_a_venir_calc = 0;
$fixedChargesList = []; $incomeList = []; $pending_charges = [];
$stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items ORDER BY name ASC");
while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
$absAmount = abs((float)$item['amount']);
$amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount;
$name = trim($item['name']);
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
$fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
}
if ($item['category'] === 'income') {
$incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
$total_income += $amt;
$budget_income_prevu += $amt;
} else {
$total_expenses_prevues += $amt;
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
$isPaid = false;
if (in_array($item['id'], $paidItemIds)) $isPaid = true;
elseif (!empty($item['mapping_keywords'])) {
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
foreach ($keywords as $kw) {
if (empty($kw)) continue;
foreach ($realExpensesLabels as $realLabel) {
if (stripos($realLabel, $kw) !== false) { $isPaid = true; break 2; }
}
}
}
if (!$isPaid) {
$reste_a_venir_calc += $absAmount;
$pending_charges[] = ['name' => $name, 'amount' => $absAmount];
}
}
if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt;
elseif ($name === 'Estimacio escola') $budget_school = $amt;
elseif ($name === 'Estimation gasolina') $budget_essence = $amt;
elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; }
}
}
$budget_autres = max(0, $total_income - $total_expenses_prevues);
// TRADUCTION DES CATÉGORIES
$categoriesConfig = [
'Income' => ['type'=>'credit', 'label'=>tr('cat_income'), 'budget'=>$budget_income_prevu, 'color'=>'#10b981', 'suggestions'=>[]],
'FMCG' => ['type'=>'debit', 'label'=>tr('cat_fmcg'), 'budget'=>$budget_fmcg, 'color'=>'#3b82f6', 'suggestions'=>['Action', 'Carrefour', 'Lidl']],
'Essence' => ['type'=>'debit', 'label'=>tr('cat_fuel'), 'budget'=>$budget_essence, 'color'=>'#f59e0b', 'suggestions'=>['Audi', 'Polo']],
'School' => ['type'=>'debit', 'label'=>tr('cat_school'), 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]],
'Frais' => ['type'=>'debit', 'label'=>tr('cat_fixed'), 'budget'=>$budget_frais, 'color'=>'#ef4444', 'suggestions'=>[]],
'Autres' => ['type'=>'debit', 'label'=>tr('cat_others'), 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']],
'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']]
];
$totals = array_fill_keys(array_keys($categoriesConfig), 0);
$expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []);
$total_rentrees = 0;
$depenses_reelles = 0;
foreach ($allExpenses as $exp) {
$cat = $exp['category'];
if (!isset($totals[$cat])) $cat = 'Autres';
$val = (float)$exp['amount'];
if ($cat === 'Income') { $totals[$cat] += $val; }
else {
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val;
else $totals[$cat] += abs($val);
}
$expensesByCategory[$cat][] = $exp;
if ($val > 0) {
if ($cat === 'Income' || $cat !== 'Frais') $total_rentrees += $val;
else $depenses_reelles -= $val;
} else {
$depenses_reelles += abs($val);
}
}
// Reste à venir dynamiques
$ecole_depense = isset($totals['School']) ? $totals['School'] : 0;
$reste_ecole = max(0, $budget_school - $ecole_depense);
if ($reste_ecole > 0) {
$reste_a_venir_calc += $reste_ecole;
$pending_charges[] = ['name' => tr('bud_rem_school'), 'amount' => $reste_ecole];
}
$fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0;
$reste_fmcg = max(0, $budget_fmcg - $fmcg_depense);
if ($reste_fmcg > 0) {
$reste_a_venir_calc += $reste_fmcg;
$pending_charges[] = ['name' => tr('bud_rem_fmcg'), 'amount' => $reste_fmcg];
}
$essence_depense = isset($totals['Essence']) ? $totals['Essence'] : 0;
$reste_essence = max(0, $budget_essence - $essence_depense);
if ($reste_essence > 0) {
$reste_a_venir_calc += $reste_essence;
$pending_charges[] = ['name' => tr('bud_rem_fuel'), 'amount' => $reste_essence];
}
// F. Calculs des KPIs finaux
$rentrees_salaires_reels = $totals['Income'] ?? 0;
$rentrees_autres = $total_rentrees - $rentrees_salaires_reels;
$salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu);
$capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres;
$solde_theorique_calc = ($solde_initial + $total_rentrees) - $depenses_reelles - $reste_a_venir_calc;
if ($isClosed) {
$solde_actuel = $monthState['solde_actuel'];
$capacite_max = $monthState['capacite_max'];
$solde_theorique = $monthState['solde_theorique'];
$reste_a_venir = $monthState['reste_a_venir'];
} else {
$solde_actuel = $snapshot['amount'];
$capacite_max = $capacite_max_calc;
$solde_theorique = $solde_theorique_calc;
$reste_a_venir = $reste_a_venir_calc;
}
$solde_net = max(0, $solde_actuel - $reste_a_venir);
$charges_visibles = min($solde_actuel, $reste_a_venir);
$max_scale = max($solde_actuel, $solde_theorique, $capacite_max, 1) * 1.1;
$pct_net = min(100, max(0, ($solde_net / $max_scale) * 100));
$pct_charges = min(100 - $pct_net, max(0, ($charges_visibles / $max_scale) * 100));
$pct_actuel = min(100, max(0, ($solde_actuel / $max_scale) * 100));
$pct_theorique = min(100, max(0, ($solde_theorique / $max_scale) * 100));
function getDisplayLogic($spent, $bg, $type) {
$pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0);
$isOver = ($type === 'debit' && $spent > $bg && $bg > 0);
$text = ($bg > 0) ? number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €' : number_format(ceil($spent), 0, ',', ' ') . ' €';
return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text];
}
// Noms des mois traduits
$monthNames = [
1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'),
5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'),
9 => tr('month_09'), 10 => tr('month_10'), 11 => tr('month_11'), 12 => tr('month_12')
];
$monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
?>
<div class="budget-view">
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:24px; border:1px solid #e2e8f0; position:relative;">
<?php if ($isClosed): ?>
<div style="position:absolute; top:0; left:0; right:0; background:#f1f5f9; padding:5px; text-align:center; font-size:0.8rem; font-weight:bold; color:#64748b; border-radius:16px 16px 0 0; border-bottom:1px solid #e2e8f0;">
🔒 <?= tr('bud_closed_archived') ?>
</div>
<div style="height:20px;"></div>
<?php endif; ?>
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom: 20px;">
<div style="display:flex; align-items:center; gap:15px;">
<h2 style="margin:0; font-size:1.3rem; color:#0f172a; text-transform:capitalize;"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
</div>
<div class="action-toolbar" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<?php if (!$isClosed): ?>
<button type="button" class="pf-btn btn-secondary" onclick="openSuiviModal('importCsvModal')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
</button>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
<input type="hidden" name="action" value="close_month">
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
<button type="submit" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669); padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔒 <?= tr('bud_close_btn') ?></button>
</form>
<?php else: ?>
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
<input type="hidden" name="action" value="reopen_month">
<button type="submit" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
</form>
<?php endif; ?>
<div class="suivi-nav-group">
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:15px; margin-bottom:25px;">
<div style="padding:15px; background:#f8fafc; border-radius:12px; position:relative; border:1px solid #e2e8f0;">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
<div style="font-size:1.4rem; font-weight:700; color:#0f172a;">
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
</div>
<?php if (!$isClosed): ?>
<button onclick="openSuiviModal('snapshotModal')" style="position:absolute; top:12px; right:12px; background:white; border:1px solid #cbd5e1; border-radius:6px; padding:4px 8px; cursor:pointer; font-size:0.8rem; color:#475569; transition:0.2s;" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='white'">✏️ <?= tr('bud_update_btn') ?></button>
<?php endif; ?>
</div>
<div style="padding:15px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0;">
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_theoretical_balance') ?></div>
<div style="font-size:1.4rem; font-weight:700; color:<?= $solde_theorique < 0 ? '#ef4444' : '#334155' ?>;">
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
</div>
</div>
<div style="padding:15px; background:#fef2f2; border-radius:12px; border:1px solid #fecaca; position: relative;">
<div style="font-size:0.85rem; color:#991b1b; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
<span><?= tr('bud_upcoming_charges') ?></span>
<button onclick="toggleDiv('pendingDetailsList')" style="background:none; border:none; cursor:pointer; font-size:1rem; padding:0; filter: grayscale(1); opacity: 0.7; transition: 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">👁️</button>
</div>
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;">
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
</div>
<div id="pendingDetailsList" style="display:none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fca5a5; font-size: 0.8rem; color: #7f1d1d;">
<?php if (empty($pending_charges)): ?>
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
<?php else: ?>
<?php foreach($pending_charges as $pc): ?>
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
<span><?= htmlspecialchars($pc['name']) ?></span>
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php
// --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE ---
$buffer = max(abs($capacite_max), abs($solde_actuel), abs($solde_theorique)) * 0.05;
$minScale = min(0, $solde_theorique, $solde_actuel) - $buffer;
$maxScale = max($capacite_max, $solde_actuel, 0) + $buffer;
$range = $maxScale - $minScale;
if ($range <= 0) $range = 1;
$posZero = (($minScale * -1) / $range) * 100;
$posActuel = (($solde_actuel - $minScale) / $range) * 100;
$posTheorique = (($solde_theorique - $minScale) / $range) * 100;
$posMax = (($capacite_max - $minScale) / $range) * 100;
$widthActuel = (abs($solde_actuel) / $range) * 100;
$leftActuel = $solde_actuel >= 0 ? $posZero : $posActuel;
$widthDanger = 0; $leftDanger = $posZero;
if ($solde_theorique < 0) {
$widthDanger = (abs($solde_theorique) / $range) * 100;
$leftDanger = $posTheorique;
}
?>
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;">
<div style="position: relative; height: 24px; background: #e2e8f0; border-radius: 12px; width: 100%; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);">
<?php if ($posZero > 0): ?>
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
<?php endif; ?>
<div style="position: absolute; left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; height: 100%; background: repeating-linear-gradient(45deg, #fbbf24, #fbbf24 10px, #f59e0b 10px, #f59e0b 20px); border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>; transition: width 0.5s ease-out;"></div>
<?php if ($solde_theorique < 0): ?>
<div style="position: absolute; left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%; height: 100%; background: repeating-linear-gradient(45deg, #ef4444, #ef4444 10px, #dc2626 10px, #dc2626 20px); border-radius: 12px 0 0 12px; opacity: 0.95;"></div>
<?php endif; ?>
</div>
<div style="position: absolute; left: <?= $posZero ?>%; top: -5px; height: 35px; width: 2px; background: #94a3b8; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posZero ?>%; top: 32px; transform: translateX(-50%); font-size: 0.85rem; font-weight: 800; color: #64748b;">0 €</div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -15px; height: 40px; width: 3px; background: #1e293b; z-index: 15;"></div>
<div style="position: absolute; left: <?= $posActuel ?>%; top: -35px; transform: translateX(-50%); font-size: 0.8rem; font-weight: bold; color: #1e293b;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 24px; height: 30px; width: 3px; background: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; z-index: 12;"></div>
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 54px; transform: translateX(-50%); font-size: 0.85rem; font-weight: bold; color: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; white-space: nowrap;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
<?php
$overlapMax = abs($posMax - $posActuel) < 35;
$topMaxText = $overlapMax ? '-60px' : '-35px';
$heightMaxLine = $overlapMax ? '65px' : '40px';
$topMaxLine = $overlapMax ? '-40px' : '-15px';
?>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>; width: 2px; background: #cbd5e1; z-index: 10;"></div>
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px)); font-size: 0.8rem; font-weight: bold; color: #64748b; white-space: nowrap; cursor: help;" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> €
</div>
</div>
</div>
<div class="categories-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:24px;">
<?php foreach ($categoriesConfig as $key => $conf): ?>
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
<div class="cat-card-title-group">
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
<div class="cat-card-subtitle">
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
</div>
</div>
<?php if (!$isClosed): ?>
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes($conf['label']) ?>')"></button>
<?php endif; ?>
</div>
<?php $barCol = ($key === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
<div style="background:#f1f5f9; height:4px; width:100%;">
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
</div>
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
<?php if (empty($expensesByCategory[$key])): ?>
<div style="padding:20px; text-align:center; color:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
<?php else: ?>
<table style="width:100%; border-collapse:collapse; font-size:0.85rem;">
<?php foreach ($expensesByCategory[$key] as $exp): ?>
<tr style="border-bottom:1px solid #f8fafc;">
<td style="padding:10px 15px; color:#94a3b8;"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
<td style="padding:10px 5px; font-weight:500;">
<?= htmlspecialchars($exp['label']) ?>
</td>
<td style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
</td>
<?php if (!$isClosed): ?>
<td style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div id="manualExpenseModal" class="pf-modal">
<div class="pf-modal-content" style="max-width:400px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 style="margin:0;" id="modalTitle"><?= tr('bud_new_transaction') ?></h3>
<button type="button" onclick="closeSuiviModal('manualExpenseModal')" class="pf-modal-close">&times;</button>
</div>
<form method="POST">
<input type="hidden" name="action" value="save_expense_manual">
<input type="hidden" name="expense_id" id="modalExpenseId">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_assign_to_month') ?> :</label>
<input type="month" name="gestion_month" id="modalGestionMonth" class="pf-input" style="font-weight:bold; color:#2563eb;" required>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_real_date') ?></label>
<input type="date" name="date" id="modalDate" class="pf-input" required>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_category') ?></label>
<select name="category" id="modalCatSelect" class="pf-input" onchange="handleModalCatChange(this)">
<?php foreach($categoriesConfig as $key => $conf): ?>
<option value="<?= $key ?>"><?= $conf['label'] ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_type') ?></label>
<select name="is_credit" id="modalIsCredit" class="pf-input">
<option value="0"><?= tr('bud_type_expense') ?> (-)</option>
<option value="1"><?= tr('bud_type_income') ?> (+)</option>
</select>
</div>
<div class="form-group" id="blockInputText" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_label') ?></label>
<input type="text" name="label" class="pf-input" id="modalLabelInput" list="modalSuggestions" autocomplete="off">
<datalist id="modalSuggestions"></datalist>
</div>
<div class="form-group" id="blockInputSelect" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_beneficiary') ?></label>
<select name="label_select" id="schoolSelect" class="pf-input">
<option value="Ecole Pol">Ecole Pol</option>
<option value="Carole">Carole</option>
</select>
</div>
<div class="form-group" id="blockInputFrais" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_fixed_charge') ?></label>
<select name="budget_item_id" id="fraisSelect" class="pf-input" disabled>
<option value=""><?= tr('bud_select_beneficiary') ?></option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
</select>
</div>
<div class="form-group" id="blockInputIncome" style="margin-bottom:15px; display:none;">
<label class="pf-label"><?= tr('bud_expected_income') ?></label>
<select name="budget_item_id" id="incomeSelect" class="pf-input" disabled>
<option value=""><?= tr('bud_select_beneficiary') ?> </option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label"><?= tr('bud_amount_eur') ?></label>
<input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input" placeholder="0.00" required>
</div>
<div class="modal-footer">
<button type="button" onclick="closeSuiviModal('manualExpenseModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
</div>
</form>
</div>
</div>
<div id="snapshotModal" class="pf-modal">
<div class="pf-modal-content" style="max-width:350px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 class="pf-modal-title" style="margin:0; border:none; padding:0;">🏦 <?= tr('bud_update_balance') ?></h3>
<button type="button" onclick="closeSuiviModal('snapshotModal')" style="background:none; border:none; font-size:1.8rem; cursor:pointer; color:#94a3b8; line-height:1;">&times;</button>
</div>
<div class="pf-modal-body">
<form id="snapshotForm" method="POST">
<input type="hidden" name="action" value="save_snapshot">
<div class="pf-form-group">
<input type="date" name="snapshot_date" class="pf-input" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="pf-form-group">
<input type="number" step="0.01" name="snapshot_amount" class="pf-input" placeholder="<?= tr('bud_balance_eur') ?>" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" onclick="closeSuiviModal('snapshotModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" form="snapshotForm" class="pf-btn"><?= tr('btn_save') ?></button>
</div>
</div>
</div>
<div id="importCsvModal" class="pf-modal <?= $showPreview ? 'open' : '' ?>">
<div class="pf-modal-content <?= $showPreview ? 'modal-large' : '' ?>">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 class="pf-modal-title" style="margin:0; border:none; padding:0;"><?= tr('bud_import_csv') ?></h3>
<button type="button" onclick="closeSuiviModal('importCsvModal')" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;">&times;</button>
</div>
<?php if (!$showPreview): ?>
<form method="POST" enctype="multipart/form-data">
<div class="import-dropzone">
<div style="font-size: 2.5rem; margin-bottom: 10px;">📄</div>
<label class="pf-label"><?= tr('bud_csv_file') ?></label>
<input type="file" name="csv_file" accept=".csv" class="pf-input" style="max-width: 300px; margin: 10px auto; display: block;" required>
</div>
<div class="modal-footer">
<button type="button" onclick="closeSuiviModal('importCsvModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
<button type="submit" class="pf-btn"><?= tr('bud_preview') ?></button>
</div>
</form>
<?php else: ?>
<form method="POST" id="formMapping">
<input type="hidden" name="action" value="save_import">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; flex-wrap:wrap; gap:10px;">
<p style="margin:0; color:#64748b; font-size:0.95rem;"><?= tr('bud_validate_import') ?? 'Veuillez vérifier et categoriser les lignes avant validation.' ?></p>
<span id="missingCount" style="color:#ef4444; background:#fee2e2; padding:4px 10px; border-radius:12px; font-weight:bold; font-size:0.85rem; display:none;"></span>
</div>
<div class="import-table-wrapper">
<table class="pf-table" style="margin:0; box-shadow:none; border-radius:0;">
<thead style="position:sticky; top:0; z-index:10; background:#f8fafc; outline: 1px solid #e2e8f0;">
<tr>
<th style="padding:10px;"><input type="checkbox" onclick="toggleAll(this)" checked></th>
<th style="padding:10px;"><?= tr('bud_assign_month') ?></th>
<th style="padding:10px;"><?= tr('bud_op_date') ?></th>
<th style="padding:10px;"><?= tr('bud_label') ?></th>
<th style="padding:10px;"><?= tr('bud_amount') ?></th>
<th style="padding:10px;"><?= tr('bud_category') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($csvData as $idx => $row):
$dup = $row['is_duplicate']; $isCrd = $row['is_credit']; $dis = $dup?'disabled':'';
$bgCol = $dup ? 'opacity:0.5' : (empty($row['cat']) && !$isCrd ? 'background:#fff1f2' : '');
?>
<tr style="<?= $bgCol ?>">
<td style="padding:8px;">
<input type="checkbox" class="line-checkbox" name="lines[<?= $idx ?>][import_check]" value="1" <?= $dup?'':'checked' ?> <?= $dis ?> onchange="checkValidation()">
<input type="hidden" name="lines[<?= $idx ?>][date]" value="<?= $row['date'] ?>">
<input type="hidden" name="lines[<?= $idx ?>][label]" value="<?= $row['label'] ?>">
<input type="hidden" name="lines[<?= $idx ?>][amount]" value="<?= $row['amount'] ?>">
<input type="hidden" name="lines[<?= $idx ?>][ref]" value="<?= $row['ref'] ?>">
<input type="hidden" class="is-credit-flag" name="lines[<?= $idx ?>][is_credit]" value="<?= $isCrd ?>">
</td>
<td style="padding:8px;">
<input type="month" name="lines[<?= $idx ?>][gestion_month]" value="<?= substr($viewMonthDate, 0, 7) ?>" class="pf-input" style="padding:4px; font-size:0.85rem;" <?= $dis ?>>
</td>
<td style="padding:8px; white-space:nowrap; font-weight:600;"><?= date('d/m', strtotime($row['date'])) ?></td>
<td style="padding:8px;"><?= htmlspecialchars($row['label']) ?></td>
<td style="padding:8px; font-weight:bold; color:<?= $isCrd ? '#10b981' : '#1e293b' ?>; white-space:nowrap;"><?= $isCrd ? '+' : '-' ?> <?= number_format($row['amount'],2) ?> €</td>
<td style="padding:8px;">
<div style="display:flex; gap:5px; min-width: 250px;">
<select name="lines[<?= $idx ?>][cat]" class="pf-input line-select" onchange="handleLineCatChange(this)" <?= $dis ?> style="padding:4px; font-size:0.85rem; flex:1;">
<option value="">-- <?= $isCrd ? tr('bud_ignore') : tr('bud_to_define') ?> --</option>
<?php foreach ($categoriesConfig as $k => $c): ?>
<option value="<?= $k ?>" <?= ($row['cat']===$k)?'selected':'' ?>><?= $c['label'] ?></option>
<?php endforeach; ?>
</select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_charge') ?> --</option>
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
</select>
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
<option value="">-- <?= tr('bud_is_income') ?> --</option>
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?>
</select>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="modal-footer" style="border-top:none; padding-top:20px;">
<a href="?tab=suivi" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></a>
<button type="submit" id="btnImport" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669);"><?= tr('btn_import') ?? 'Importer les lignes cochées' ?></button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<script>
<?php if ($showPreview): ?>
document.body.classList.add('no-scroll');
<?php endif; ?>
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
window.I18N = {
...(window.I18N || {}),
'bud_confirm_delete': <?= json_encode(tr('bud_confirm_delete')) ?>,
'bud_to_define_js': <?= json_encode(tr('bud_to_define_js')) ?>,
'error_occured': <?= json_encode(tr('error_occured')) ?>,
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
'btn_cancel': <?= json_encode(tr('btn_cancel')) ?>,
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
};
const activeViewMonth = '<?= substr($viewMonthDate, 0, 7) ?>';
function toggleDiv(id) { const el = document.getElementById(id); el.style.display = (el.style.display === 'none') ? 'block' : 'none'; }
function openSuiviModal(id) { document.getElementById(id).classList.add('open'); document.body.classList.add('no-scroll'); }
function closeSuiviModal(id) { document.getElementById(id).classList.remove('open'); document.body.classList.remove('no-scroll');}
const suggestions = <?= json_encode(array_map(fn($c) => $c['suggestions'], $categoriesConfig)) ?>;
function handleModalCatChange(select) {
const catKey = select.value;
document.getElementById('blockInputText').style.display = 'none';
document.getElementById('blockInputSelect').style.display = 'none';
document.getElementById('blockInputFrais').style.display = 'none';
document.getElementById('blockInputIncome').style.display = 'none';
document.getElementById('modalLabelInput').required = false;
document.getElementById('fraisSelect').required = false;
document.getElementById('incomeSelect').required = false;
document.getElementById('fraisSelect').disabled = true;
document.getElementById('incomeSelect').disabled = true;
if (catKey === 'School') { document.getElementById('blockInputSelect').style.display = 'block'; }
else if (catKey === 'Frais') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputFrais').style.display = 'block'; document.getElementById('fraisSelect').required = true; document.getElementById('fraisSelect').disabled = false; }
else if (catKey === 'Income') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputIncome').style.display = 'block'; document.getElementById('incomeSelect').required = true; document.getElementById('incomeSelect').disabled = false; }
else {
document.getElementById('blockInputText').style.display = 'block'; document.getElementById('modalLabelInput').required = true;
const list = document.getElementById('modalSuggestions'); list.innerHTML = '';
if (suggestions[catKey]) { suggestions[catKey].forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); }); }
}
}
function openAddModal(catKey, catLabel) {
openSuiviModal('manualExpenseModal');
document.getElementById('modalTitle').innerText = "<?= tr('bud_add_title') ?> : " + catLabel;
document.getElementById('modalExpenseId').value = "";
document.getElementById('modalDate').value = new Date().toISOString().split('T')[0];
document.getElementById('modalGestionMonth').value = activeViewMonth;
document.getElementById('modalLabelInput').value = "";
document.getElementById('modalAmount').value = "";
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = catKey; handleModalCatChange(catSelect);
document.getElementById('modalIsCredit').value = (catKey === 'Income') ? "1" : "0";
}
function openEditModal(e) {
openSuiviModal('manualExpenseModal');
document.getElementById('modalTitle').innerText = "<?= tr('bud_edit_title') ?>";
document.getElementById('modalExpenseId').value = e.id;
document.getElementById('modalDate').value = e.date_exp;
document.getElementById('modalGestionMonth').value = e.gestion_month ? e.gestion_month.substring(0, 7) : activeViewMonth;
document.getElementById('modalLabelInput').value = e.label;
document.getElementById('modalIsCredit').value = parseFloat(e.amount) > 0 ? "1" : "0";
document.getElementById('modalAmount').value = Math.abs(parseFloat(e.amount));
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = e.category; handleModalCatChange(catSelect);
if (e.category === 'Frais') document.getElementById('fraisSelect').value = e.budget_item_id;
else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id;
}
function handleLineCatChange(select) {
const row = select.closest('tr');
const fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income');
fSel.style.display = 'none'; iSel.style.display = 'none'; fSel.value = ''; iSel.value = ''; fSel.disabled = true; iSel.disabled = true;
if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; }
else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; }
checkValidation();
}
function toggleAll(src) { document.querySelectorAll('.line-checkbox:not([disabled])').forEach(c => c.checked = src.checked); checkValidation(); }
function checkValidation() {
let miss = 0;
document.querySelectorAll('.line-checkbox:checked').forEach(cb => {
const row = cb.closest('tr'); const isCrd = row.querySelector('.is-credit-flag').value === '1'; const cat = row.querySelector('.line-select').value;
let v = true; if (cat==="") { if(!isCrd) v = false; } else if (cat==='Frais' && row.querySelector('.select-frais').value==="") v=false; else if (cat==='Income' && row.querySelector('.select-income').value==="") v=false;
if (!v) { miss++; row.style.background = '#fff1f2'; } else row.style.background = '';
});
const btn = document.getElementById('btnImport'); const msg = document.getElementById('missingCount');
if(miss>0) { btn.disabled = true; btn.style.opacity=0.5; msg.style.display='inline'; msg.innerText = miss + ' ' + (window.I18N['bud_to_define_js'] || ''); }
else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; }
}
if(document.getElementById('formMapping')) { document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s)); checkValidation(); }
window.addEventListener('click', (e) => {
if (e.target.classList.contains('pf-modal')) {
e.target.style.display = 'none';
document.body.classList.remove('no-scroll');
}
});
// --- 2. SUPPRESSION ASYNCHRONE ---
async function deleteExpense(id) {
const confirmed = await pachaConfirm("Suppression", tr('bud_confirm_delete'));
if (!confirmed) return;
const formData = new FormData();
formData.append("action", "delete_expense");
formData.append("id", id);
try {
await pachaFetch("modules/budget/includes/api/manage-item.php", { method: "POST", body: formData });
showToast(window.I18N['bud_toast_deleted'] || "Supprimé");
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(window.I18N['error_occured'] || "Erreur", "error");
}
}
// --- 3. SAUVEGARDE ASYNCHRONE (AJOUT/MODIF) ---
document.addEventListener('DOMContentLoaded', () => {
const formExpense = document.querySelector('#manualExpenseModal form');
if (formExpense) {
formExpense.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = formExpense.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerText;
try {
submitBtn.disabled = true;
submitBtn.innerText = '⏳ ...';
const formData = new FormData(formExpense);
// Force l'action ici pour être sûr
formData.set('action', 'save_expense_manual');
const actionUrl = formExpense.getAttribute('action') || 'modules/budget/includes/api/manage-item.php';
const result = await pachaFetch(actionUrl, {
method: 'POST',
body: formData
});
if (result.success) {
closeSuiviModal('manualExpenseModal');
formExpense.reset();
window.location.reload();
} else {
alert((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue'));
}
} catch (error) {
console.error("Erreur réseau :", error);
alert(window.I18N['bud_err_tech'] || "Erreur critique réseau.");
} finally {
submitBtn.disabled = false;
submitBtn.innerText = originalBtnText;
}
});
}
});
</script>
+418
View File
@@ -0,0 +1,418 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
require_once dirname(__DIR__, 2) . '/includes/db.php';
require_once dirname(__DIR__, 2) . '/includes/meta_db.php';
require_once __DIR__ . '/caldav_sync.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$userId = (int)($_SESSION['user']['id'] ?? 0);
$familyId = (int)($_SESSION['user']['family_id'] ?? 0);
$meta_pdo->exec("
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
username VARCHAR(255) NOT NULL,
secret_encrypted TEXT NOT NULL,
dav_principal_url VARCHAR(1024) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
status VARCHAR(30) DEFAULT 'connected',
last_sync_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_provider (user_id, provider)
)");
try {
$meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL');
} catch (Throwable $e) {
// colonne déjà présente
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
family_id INT NOT NULL,
created_by_user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT NULL,
location VARCHAR(255) DEFAULT NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
is_all_day TINYINT(1) DEFAULT 0,
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
rrule VARCHAR(500) DEFAULT NULL,
status VARCHAR(50) DEFAULT 'confirmed',
external_uid VARCHAR(255) DEFAULT NULL,
sync_state VARCHAR(30) DEFAULT 'pending_push',
deleted_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_external_uid (external_uid)
)");
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_event_id INT NOT NULL,
external_uid VARCHAR(255) NOT NULL,
external_etag VARCHAR(255) DEFAULT NULL,
calendar_url VARCHAR(1024) DEFAULT NULL,
external_href VARCHAR(2048) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_calendar_event (calendar_event_id),
UNIQUE KEY uq_external_link (external_uid)
)");
try {
$pdo->exec('ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL');
} catch (Throwable $e) {
// colonne déjà présente
}
function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; }
function ios_body(): array { return json_decode(file_get_contents('php://input'), true) ?? []; }
function ios_require_csrf(): void {
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!verify_csrf($token)) {
ios_err('Token CSRF invalide', 403);
}
}
function ios_normalize_datetime(?string $s): ?string
{
if ($s === null || $s === '') {
return null;
}
$s = str_replace('T', ' ', trim($s));
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s)) {
$s .= ':00';
}
return $s;
}
function ios_calendar_url_key(?string $url): string
{
if ($url === null || $url === '') {
return '';
}
return rtrim(trim($url), '/');
}
/** @return array<string, array{visible:bool, color:?string}> */
function ios_calendar_prefs_decode(?string $json): array
{
if ($json === null || $json === '') {
return [];
}
$d = json_decode($json, true);
if (!is_array($d)) {
return [];
}
$out = [];
foreach ($d as $k => $v) {
if (!is_string($k) || $k === '' || !is_array($v)) {
continue;
}
$key = ios_calendar_url_key($k);
if ($key === '') {
continue;
}
$out[$key] = [
'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'],
'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null,
];
}
return $out;
}
/**
* @return list<array{url:string,url_key:string,display:string,visible:bool,color:?string}>
*/
function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array
{
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
$urlsMeta = [];
$primary = $integration['calendar_url'] ?? '';
if (hh_caldav_url_is_icloud($primary)) {
try {
foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) {
$k = ios_calendar_url_key($e['url']);
if ($k === '') {
continue;
}
$disp = trim((string) ($e['display'] ?? ''));
if ($disp === '') {
$disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k;
}
$urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp];
}
} catch (Throwable $e) {
}
} else {
$k = ios_calendar_url_key($primary);
if ($k !== '') {
$urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier'];
}
}
$stmt = $pdo->query("
SELECT DISTINCT l.calendar_url AS u
FROM pf_calendar_event_links l
INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id
WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != ''
");
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) {
$k = ios_calendar_url_key((string) $u);
if ($k === '' || isset($urlsMeta[$k])) {
continue;
}
$urlsMeta[$k] = [
'url' => rtrim((string) $u, '/') . '/',
'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k,
];
}
$out = [];
foreach ($urlsMeta as $key => $meta) {
$p = $prefs[$key] ?? [];
$visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible'];
$color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null;
$out[] = [
'url' => $meta['url'],
'url_key' => $key,
'display' => $meta['display'],
'visible' => $visible,
'color' => $color,
];
}
usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display']));
return $out;
}
$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
$integrationStmt->execute([$userId]);
$integration = $integrationStmt->fetch();
if ($action === 'events' && $method === 'GET') {
$prefs = [];
if ($integration) {
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
}
$rows = $pdo->query("
SELECT e.*, l.calendar_url AS calendar_source_url
FROM pf_calendar_events e
LEFT JOIN pf_calendar_event_links l ON l.calendar_event_id = e.id
WHERE e.deleted_at IS NULL
ORDER BY e.start_at ASC
")->fetchAll();
$out = [];
foreach ($rows as $r) {
$key = ios_calendar_url_key($r['calendar_source_url'] ?? '');
if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) {
continue;
}
$r['display_color'] = null;
if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) {
$r['display_color'] = $prefs[$key]['color'];
}
$out[] = $r;
}
ios_ok($out);
}
if ($action === 'calendar_sources' && $method === 'GET') {
if (!$integration) {
ios_ok(['calendars' => []]);
}
try {
$ctx = ios_caldav_prepare($integration, $meta_pdo);
} catch (Throwable $e) {
ios_err('CalDAV: ' . $e->getMessage(), 400);
}
$integrationStmt->execute([$userId]);
$integrationFresh = $integrationStmt->fetch();
if (!$integrationFresh) {
ios_ok(['calendars' => []]);
}
ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]);
}
if ($action === 'calendar_prefs' && $method === 'POST') {
ios_require_csrf();
if (!$integration) {
ios_err('Connexion iCloud non configurée.', 400);
}
$body = ios_body();
$raw = $body['prefs'] ?? null;
if (!is_array($raw)) {
ios_err('Format prefs invalide', 400);
}
$clean = [];
foreach ($raw as $key => $p) {
if (!is_array($p)) {
continue;
}
$urlKey = ios_calendar_url_key(is_string($key) ? $key : '');
if ($urlKey === '') {
continue;
}
$visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true;
$col = $p['color'] ?? null;
if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) {
$col = '#' . strtolower(substr($col, 1));
} else {
$col = null;
}
$clean[$urlKey] = ['visible' => $visible, 'color' => $col];
}
$existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
foreach ($clean as $urlKey => $v) {
$existing[$urlKey] = $v;
}
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]);
ios_ok(['saved' => true]);
}
if ($action === 'events' && $method === 'POST') {
ios_require_csrf();
$d = ios_body();
if (empty($d['title']) || empty($d['start_at']) || empty($d['end_at'])) {
ios_err('Champs requis manquants');
}
$stmt = $pdo->prepare("
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state)
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push')
");
$stmt->execute([
$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null,
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null),
]);
ios_ok(['id' => (int)$pdo->lastInsertId()]);
}
if ($action === 'events' && $method === 'PUT') {
ios_require_csrf();
$d = ios_body();
$id = (int)($d['id'] ?? 0);
if ($id <= 0) ios_err('ID invalide');
$stmt = $pdo->prepare("
UPDATE pf_calendar_events
SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
WHERE id=?
");
$stmt->execute([
trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null,
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), $id,
]);
ios_ok(['updated' => true]);
}
if ($action === 'events' && $method === 'DELETE') {
ios_require_csrf();
$d = ios_body();
$id = (int)($d['id'] ?? 0);
if ($id <= 0) ios_err('ID invalide');
$row = $pdo->prepare("SELECT id FROM pf_calendar_events WHERE id=?");
$row->execute([$id]);
if (!$row->fetch()) ios_err('Événement introuvable', 404);
$pdo->prepare("UPDATE pf_calendar_events SET deleted_at=NOW(), sync_state='pending_delete' WHERE id=?")->execute([$id]);
ios_ok(['deleted' => true]);
}
if ($action === 'sync_status' && $method === 'GET') {
if (!$integration) {
ios_ok(['message' => 'Aucune intégration iCloud configurée.']);
}
$msg = 'Connecté iCloud. Dernière synchro: ' . ($integration['last_sync_at'] ?? 'jamais');
ios_ok(['message' => $msg]);
}
if ($action === 'sync' && $method === 'POST') {
ios_require_csrf();
if (!$integration) ios_err('Connexion iCloud non configurée.', 400);
try {
$ctx = ios_caldav_prepare($integration, $meta_pdo);
$integration = $ctx['integration'];
$davPassword = $ctx['password'];
$syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword);
$remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword);
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
$localEvents = $localStmt->fetchAll();
$linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?');
foreach ($localEvents as $evt) {
if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
$linkStmt->execute([$evt['id']]);
$lr = $linkStmt->fetch() ?: [];
$push = ios_push_event_to_remote(
$integration,
$evt,
$davPassword,
$lr['external_href'] ?? null,
$lr['calendar_url'] ?? null
);
if ($push['code'] >= 200 && $push['code'] < 300) {
$uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub');
$pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]);
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
->execute([$evt['id'], $uid, $integration['calendar_url'], null, $lr['external_href'] ?? null]);
}
}
}
$pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll();
foreach ($pendingDelete as $evt) {
if (!empty($evt['external_uid'])) {
$linkStmt->execute([$evt['id']]);
$lr = $linkStmt->fetch() ?: [];
ios_delete_remote_event(
$integration,
$evt['external_uid'],
$davPassword,
$lr['external_href'] ?? null,
$lr['calendar_url'] ?? null
);
}
$pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]);
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
}
foreach ($remoteEvents as $remote) {
$href = $remote['_resource_url'] ?? null;
$colUrl = $remote['_calendar_collection_url'] ?? $integration['calendar_url'];
$existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
$existing->execute([$remote['external_uid']]);
$row = $existing->fetch();
if (!$row) {
$pdo->prepare("
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state)
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
$newId = (int)$pdo->lastInsertId();
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
->execute([$newId, $remote['external_uid'], $colUrl, null, $href]);
} elseif ($href) {
$pdo->prepare('UPDATE pf_calendar_event_links SET external_href = COALESCE(external_href, ?), calendar_url = COALESCE(calendar_url, ?) WHERE calendar_event_id = ?')
->execute([$href, $colUrl, $row['id']]);
}
}
$meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
$n = count($remoteEvents);
$nc = count($syncUrls);
ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) depuis ' . $nc . ' calendrier(s) distant(s).']);
} catch (Throwable $e) {
ios_err('CalDAV: ' . $e->getMessage(), 400);
}
}
ios_err('Action inconnue', 404);
@@ -0,0 +1,600 @@
/* ─── Page layout (cartes claires hors agenda) ─────────────────────────── */
.ios-calendar-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.ios-calendar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ios-calendar-head h1 {
margin: 0;
font-size: 1.4rem;
}
.ios-agenda-intro {
margin: 0;
font-size: 0.9rem;
color: var(--pf-text-muted, #64748b);
line-height: 1.45;
}
.ios-cal-prefs-details {
margin-top: 14px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 10px;
padding: 10px 14px;
background: var(--pf-bg-page, #fff);
}
.ios-cal-prefs-summary {
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
}
.ios-cal-prefs-help {
margin: 10px 0 12px;
font-size: 0.85rem;
color: var(--pf-text-muted, #64748b);
line-height: 1.45;
}
.ios-cal-prefs-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.ios-cal-pref-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px 12px;
padding: 8px 10px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
background: var(--pf-bg-lighter, #f8fafc);
}
.ios-cal-pref-row input[type="checkbox"] {
width: 18px;
height: 18px;
}
.ios-cal-pref-label {
font-size: 0.88rem;
min-width: 0;
word-break: break-word;
}
.ios-cal-pref-color {
width: 44px;
height: 36px;
padding: 2px;
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 8px;
cursor: pointer;
background: var(--pf-bg-page, #fff);
}
.ios-cal-pref-reset {
border: none;
background: transparent;
color: var(--pf-text-muted, #64748b);
font-size: 0.78rem;
text-decoration: underline;
cursor: pointer;
padding: 4px;
font-family: inherit;
white-space: nowrap;
}
.ios-cal-pref-reset:hover {
color: var(--pf-text-main, #0f172a);
}
.ios-cal-prefs-msg {
min-height: 1.25em;
font-size: 0.85rem;
margin: 0 0 10px;
}
@media (max-width: 640px) {
.ios-cal-pref-row {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.ios-cal-pref-color {
grid-column: 2;
}
.ios-cal-pref-reset {
grid-column: 2;
justify-self: start;
}
}
.ios-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.ios-form-full {
grid-column: 1 / -1;
}
.ios-form-actions {
display: flex;
gap: 8px;
}
.ios-events-list {
display: grid;
gap: 10px;
}
.ios-event-card {
border: 1px solid var(--pf-border, #e2e8f0);
border-radius: 10px;
padding: 12px;
background: var(--pf-bg-page, #fff);
}
.ios-event-card-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.ios-event-card-title {
font-weight: 700;
}
.ios-event-card-meta {
color: var(--pf-text-muted, #64748b);
font-size: 0.9rem;
}
.ios-event-card-actions {
display: flex;
gap: 8px;
}
/* ─── Agenda sombre (style type app calendrier) ─────────────────────────── */
.ios-agenda-card {
--ios-bg: #0a0a0c;
--ios-elev: #12121a;
--ios-border: rgba(255, 255, 255, 0.08);
--ios-text: #f1f5f9;
--ios-muted: #94a3b8;
--ios-now: #ef4444;
--ios-seg-bg: #1a1a22;
background: var(--ios-bg);
border: 1px solid var(--ios-border);
color: var(--ios-text);
border-radius: 12px;
overflow: hidden;
}
.ios-agenda-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--ios-border);
background: var(--ios-elev);
}
.ios-agenda-title {
font-size: 1.05rem;
font-weight: 700;
text-transform: capitalize;
min-width: 140px;
}
.ios-agenda-segments {
display: inline-flex;
background: var(--ios-seg-bg);
border-radius: 10px;
padding: 3px;
gap: 2px;
}
.ios-seg {
border: none;
background: transparent;
color: var(--ios-muted);
padding: 7px 16px;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ios-seg:hover {
color: var(--ios-text);
}
.ios-seg--active {
background: #2d2d38;
color: var(--ios-text);
font-weight: 600;
}
.ios-agenda-nav {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.ios-agenda-btn {
border: 1px solid var(--ios-border);
background: var(--ios-elev);
color: var(--ios-text);
border-radius: 8px;
padding: 6px 12px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ios-agenda-btn:hover {
background: #1e1e28;
}
.ios-agenda-btn--ghost {
background: transparent;
}
.ios-agenda-today {
border: none;
background: transparent;
color: var(--ios-now);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
padding: 6px 10px;
font-family: inherit;
}
.ios-agenda-add {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--ios-border);
background: var(--ios-elev);
color: var(--ios-text);
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
}
.ios-agenda-add:hover {
background: #1e1e28;
}
.ios-agenda-listlink {
border: none;
background: transparent;
color: var(--ios-muted);
font-size: 0.82rem;
text-decoration: underline;
cursor: pointer;
padding: 6px 8px;
font-family: inherit;
}
.ios-agenda-listlink:hover {
color: var(--ios-text);
}
.ios-agenda-mount {
min-height: 200px;
}
.ios-agenda-list-hint {
margin: 0;
padding: 20px 16px;
color: var(--ios-muted);
font-size: 0.9rem;
line-height: 1.5;
}
/* Grille semaine / jour */
.ios-week-wrap {
display: flex;
flex-direction: column;
}
.ios-week-head {
display: grid;
grid-template-columns: 52px repeat(7, minmax(0, 1fr));
border-bottom: 1px solid var(--ios-border);
background: var(--ios-elev);
font-size: 0.78rem;
color: var(--ios-muted);
}
.ios-week-head--day {
grid-template-columns: 52px minmax(0, 1fr);
}
.ios-week-corner {
border-right: 1px solid var(--ios-border);
}
.ios-week-head-day {
text-align: center;
padding: 10px 4px;
border-left: 1px solid var(--ios-border);
}
.ios-week-head-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
border-radius: 50%;
font-weight: 700;
color: var(--ios-text);
margin-right: 4px;
}
.ios-week-head-day--today .ios-week-head-num {
background: var(--ios-now);
color: #fff;
}
.ios-week-scroll {
max-height: min(70vh, 720px);
overflow-y: auto;
overflow-x: auto;
}
.ios-week-track {
display: flex;
min-width: min(100%, 720px);
align-items: stretch;
}
.ios-agenda-gutter {
width: 52px;
flex-shrink: 0;
border-right: 1px solid var(--ios-border);
background: var(--ios-bg);
}
.ios-agenda-gutter-cell {
height: 44px;
font-size: 0.72rem;
color: var(--ios-muted);
text-align: right;
padding-right: 8px;
line-height: 44px;
box-sizing: border-box;
}
.ios-agenda-cols-wrap {
flex: 1;
position: relative;
min-width: 0;
display: flex;
min-height: 704px;
}
.ios-agenda-cols {
flex: 1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
min-width: 0;
min-height: 704px;
}
.ios-agenda-cols--day {
grid-template-columns: minmax(0, 1fr);
}
.ios-agenda-col {
border-left: 1px solid var(--ios-border);
background-color: var(--ios-bg);
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 43px,
var(--ios-border) 43px,
var(--ios-border) 44px
);
}
.ios-agenda-col:first-child {
border-left: none;
}
.ios-agenda-col-inner {
position: relative;
}
.ios-event-block {
position: absolute;
border: none;
border-radius: 6px;
padding: 4px 6px;
text-align: left;
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
color: #fff;
font-size: 0.72rem;
line-height: 1.25;
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35);
}
.ios-event-block--custom {
border: none !important;
color: #fff !important;
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important;
}
.ios-event-block-title {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ios-cal-c0 {
background: linear-gradient(135deg, #166534, #15803d);
}
.ios-cal-c1 {
background: linear-gradient(135deg, #1e40af, #2563eb);
}
.ios-cal-c2 {
background: linear-gradient(135deg, #6b21a8, #7c3aed);
}
.ios-cal-c3 {
background: linear-gradient(135deg, #a16207, #ca8a04);
}
.ios-cal-c4 {
background: linear-gradient(135deg, #0f766e, #14b8a6);
}
.ios-cal-c5 {
background: linear-gradient(135deg, #9f1239, #e11d48);
}
.ios-now-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
}
.ios-now-line-span {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--ios-now);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
}
.ios-now-lbl-span {
position: absolute;
left: 6px;
font-size: 0.68rem;
font-weight: 700;
color: var(--ios-now);
text-shadow: 0 0 6px #000;
}
/* Mois (dans carte sombre) */
.ios-month-agenda {
padding: 12px 14px 16px;
}
.ios-month-agenda-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
font-size: 0.72rem;
color: var(--ios-muted);
text-align: center;
}
.ios-month-agenda-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.ios-month-cell {
aspect-ratio: 1;
min-height: 40px;
border: 1px solid var(--ios-border);
border-radius: 8px;
background: var(--ios-elev);
color: var(--ios-text);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px 2px;
font: inherit;
}
.ios-month-cell--pad {
visibility: hidden;
pointer-events: none;
border: none;
background: transparent;
}
.ios-month-cell--today {
border-color: var(--ios-now);
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35);
}
.ios-month-cell--has {
background: #1a1a24;
}
.ios-month-num {
font-size: 0.85rem;
font-weight: 600;
}
.ios-month-dots {
display: flex;
gap: 2px;
flex-wrap: wrap;
justify-content: center;
margin-top: 2px;
}
.ios-month-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
}
@media (max-width: 768px) {
.ios-form-grid {
grid-template-columns: 1fr;
}
.ios-calendar-head {
flex-direction: column;
align-items: flex-start;
}
.ios-event-card-head {
flex-direction: column;
align-items: flex-start;
}
.ios-agenda-toolbar {
flex-direction: column;
align-items: stretch;
}
.ios-agenda-segments {
justify-content: center;
}
.ios-agenda-nav {
justify-content: center;
}
}
+700
View File
@@ -0,0 +1,700 @@
const iosEventsList = document.getElementById("ios-events-list");
const iosSyncStatus = document.getElementById("ios-sync-status");
const iosForm = document.getElementById("ios-event-form");
const iosAgendaMount = document.getElementById("ios-agenda-mount");
const iosAgendaTitle = document.getElementById("ios-agenda-title");
const H_START = 6;
const H_END = 22;
const PX_PER_H = 44;
const TRACK_H = (H_END - H_START) * PX_PER_H;
let iosEventsCache = [];
/** @type {'day'|'week'|'month'} */
let iosViewMode = "week";
/** Ancre de navigation (jour courant dans la période affichée) */
let iosCursor = new Date();
let iosNowTimer = null;
let iosListVisible = false;
const PALETTE = [
"ios-cal-c0",
"ios-cal-c1",
"ios-cal-c2",
"ios-cal-c3",
"ios-cal-c4",
"ios-cal-c5",
];
const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"];
function escHtml(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function sqlToDatetimeLocal(s) {
if (!s) return "";
const m = /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/.exec(String(s).trim());
if (!m) return "";
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`;
}
function localDatetimeToSql(s) {
if (!s) return "";
const t = s.includes("T") ? s.replace("T", " ") : s;
return t.length === 16 ? `${t}:00` : t;
}
function parseEvtDate(iso) {
const d = new Date(String(iso).replace(" ", "T"));
return Number.isNaN(d.getTime()) ? null : d;
}
function darkenHex(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const r = Math.max(0, (n >> 16) - amount);
const g = Math.max(0, ((n >> 8) & 0xff) - amount);
const b = Math.max(0, (n & 0xff) - amount);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
function suggestedColorForKey(key) {
const k = String(key || "");
let h = 0;
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
return SUGGEST_HEX[h % SUGGEST_HEX.length];
}
function colorClassForEvent(evt) {
const key = String(evt.calendar_source_url || evt.external_uid || evt.id || "");
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
/** Applique couleur perso (API) ou palette par défaut */
function styleEventBlock(el, evt) {
el.className = "ios-event-block";
el.style.background = "";
el.style.boxShadow = "";
const hex = evt.display_color;
if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) {
const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`;
el.classList.add("ios-event-block--custom");
el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`;
el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)";
} else {
el.classList.add(colorClassForEvent(evt));
}
}
function mondayOf(date) {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = d.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
d.setDate(d.getDate() + diff);
return d;
}
function addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function addMonths(d, n) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
function sameLocalDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
/** Segment dun événement visible dans [dayMidnight, dayMidnight+1j[ */
function segmentInLocalDay(evt, dayMidnight) {
const s = parseEvtDate(evt.start_at);
const e = parseEvtDate(evt.end_at);
if (!s || !e) return null;
const dayEnd = addDays(dayMidnight, 1);
if (e <= dayMidnight || s >= dayEnd) return null;
const segStart = s > dayMidnight ? s : dayMidnight;
const segEnd = e < dayEnd ? e : dayEnd;
return { segStart, segEnd, evt };
}
function pxInTrackFromHourFraction(dayMidnight, t) {
const grid0 = new Date(dayMidnight);
grid0.setHours(H_START, 0, 0, 0);
const grid1 = new Date(dayMidnight);
grid1.setHours(H_END, 0, 0, 0);
const tt = Math.min(Math.max(t.getTime(), grid0.getTime()), grid1.getTime());
return ((tt - grid0.getTime()) / 3600000) * PX_PER_H;
}
function heightPxForSegment(dayMidnight, segStart, segEnd) {
const a = pxInTrackFromHourFraction(dayMidnight, segStart);
const b = pxInTrackFromHourFraction(dayMidnight, segEnd);
return Math.max(18, b - a);
}
function nowLineTopPx(colDay) {
const now = new Date();
if (!sameLocalDay(now, colDay)) return null;
return pxInTrackFromHourFraction(colDay, now);
}
function assignLanes(segments) {
if (segments.length === 0) return;
segments.sort((a, b) => a.segStart - b.segStart);
const laneEnds = [];
segments.forEach((seg) => {
let L = 0;
while (laneEnds[L] && seg.segStart < laneEnds[L]) L++;
laneEnds[L] = seg.segEnd;
seg._lane = L;
});
const maxL = segments.reduce((m, s) => Math.max(m, s._lane), 0);
segments.forEach((s) => {
s._laneCount = maxL + 1;
});
}
async function iosApi(action, options = {}) {
const method = options.method || "GET";
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
};
if (method !== "GET") {
headers["X-CSRF-Token"] = window.CSRF_TOKEN || "";
headers["Content-Type"] = "application/json";
}
const response = await fetch(`/modules/calendar-ios/api.php?action=${encodeURIComponent(action)}`, {
method,
credentials: "same-origin",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const json = await response.json();
if (!json.ok) throw new Error(json.error || "Erreur inconnue");
return json.data;
}
function formatDate(iso) {
const d = parseEvtDate(iso);
return d ? d.toLocaleString("fr-FR") : iso;
}
function fillForm(evt) {
document.getElementById("ios-event-id").value = evt.id;
document.getElementById("ios-title").value = evt.title || "";
document.getElementById("ios-location").value = evt.location || "";
document.getElementById("ios-description").value = evt.description || "";
document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at);
document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at);
}
function resetForm() {
iosForm.reset();
document.getElementById("ios-event-id").value = "";
}
function eventsForLocalDay(y, m, d) {
return iosEventsCache.filter((evt) => {
const t = parseEvtDate(evt.start_at);
return t && t.getFullYear() === y && t.getMonth() === m && t.getDate() === d;
});
}
function updateToolbarTitle() {
if (!iosAgendaTitle) return;
if (iosViewMode === "day") {
iosAgendaTitle.textContent = iosCursor.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
return;
}
if (iosViewMode === "week") {
const mon = mondayOf(iosCursor);
const sun = addDays(mon, 6);
if (mon.getMonth() === sun.getMonth() && mon.getFullYear() === sun.getFullYear()) {
iosAgendaTitle.textContent = `${mon.getDate()} ${sun.getDate()} ${sun.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}`;
} else {
iosAgendaTitle.textContent = `${mon.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })} ${sun.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" })}`;
}
return;
}
iosAgendaTitle.textContent = new Date(iosCursor.getFullYear(), iosCursor.getMonth(), 1).toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
}
function setActiveSegments() {
document.querySelectorAll(".ios-seg").forEach((b) => {
b.classList.toggle("ios-seg--active", b.getAttribute("data-ios-view") === iosViewMode);
});
}
function renderTimeGutter() {
const frag = document.createDocumentFragment();
for (let h = H_START; h < H_END; h++) {
const row = document.createElement("div");
row.className = "ios-agenda-gutter-cell";
row.textContent = `${h} h`;
frag.appendChild(row);
}
return frag;
}
function buildDayColumn(dayMidnight) {
const col = document.createElement("div");
col.className = "ios-agenda-col";
const inner = document.createElement("div");
inner.className = "ios-agenda-col-inner";
inner.style.height = `${TRACK_H}px`;
const segs = [];
iosEventsCache.forEach((evt) => {
const seg = segmentInLocalDay(evt, dayMidnight);
if (seg) segs.push(seg);
});
assignLanes(segs);
segs.forEach((seg) => {
const top = pxInTrackFromHourFraction(dayMidnight, seg.segStart);
const h = heightPxForSegment(dayMidnight, seg.segStart, seg.segEnd);
const w = 100 / seg._laneCount;
const left = (100 * seg._lane) / seg._laneCount;
const blk = document.createElement("button");
blk.type = "button";
styleEventBlock(blk, seg.evt);
blk.style.top = `${top}px`;
blk.style.height = `${h}px`;
blk.style.left = `calc(${left}% + 2px)`;
blk.style.width = `calc(${w}% - 4px)`;
blk.innerHTML = `<span class="ios-event-block-title">${escHtml(seg.evt.title || "(Sans titre)")}</span>`;
blk.title = [seg.evt.title, seg.evt.location, seg.evt.calendar_source_url].filter(Boolean).join("\n");
blk.addEventListener("click", () => {
fillForm(seg.evt);
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
inner.appendChild(blk);
});
col.appendChild(inner);
return col;
}
function renderWeekOrDay() {
if (!iosAgendaMount) return;
const isDay = iosViewMode === "day";
const weekStart = isDay ? new Date(iosCursor.getFullYear(), iosCursor.getMonth(), iosCursor.getDate()) : mondayOf(iosCursor);
const days = isDay ? [weekStart] : Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const wrap = document.createElement("div");
wrap.className = "ios-week-wrap";
const head = document.createElement("div");
head.className = "ios-week-head" + (isDay ? " ios-week-head--day" : "");
const corner = document.createElement("div");
corner.className = "ios-week-corner";
head.appendChild(corner);
const today = new Date();
days.forEach((d) => {
const th = document.createElement("div");
th.className = "ios-week-head-day";
if (sameLocalDay(d, today)) th.classList.add("ios-week-head-day--today");
const num = document.createElement("span");
num.className = "ios-week-head-num";
num.textContent = String(d.getDate());
const wd = d.toLocaleDateString("fr-FR", { weekday: "short" }).replace(/\.$/, "");
th.appendChild(num);
th.appendChild(document.createTextNode(` ${wd}.`));
head.appendChild(th);
});
wrap.appendChild(head);
const scroll = document.createElement("div");
scroll.className = "ios-week-scroll";
const track = document.createElement("div");
track.className = "ios-week-track";
const gutter = document.createElement("div");
gutter.className = "ios-agenda-gutter";
gutter.appendChild(renderTimeGutter());
track.appendChild(gutter);
const colsWrap = document.createElement("div");
colsWrap.className = "ios-agenda-cols-wrap";
const cols = document.createElement("div");
cols.className = `ios-agenda-cols${isDay ? " ios-agenda-cols--day" : ""}`;
days.forEach((d0) => {
const dm = new Date(d0.getFullYear(), d0.getMonth(), d0.getDate());
cols.appendChild(buildDayColumn(dm));
});
colsWrap.appendChild(cols);
const todayCol = days.findIndex((d) => sameLocalDay(d, new Date()));
if (todayCol >= 0) {
const dm = new Date(days[todayCol].getFullYear(), days[todayCol].getMonth(), days[todayCol].getDate());
const nowTop = nowLineTopPx(dm);
if (nowTop !== null) {
const ov = document.createElement("div");
ov.className = "ios-now-overlay";
const line = document.createElement("div");
line.className = "ios-now-line-span";
line.style.top = `${nowTop}px`;
const lbl = document.createElement("div");
lbl.className = "ios-now-lbl-span";
lbl.textContent = new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
lbl.style.top = `${Math.max(2, nowTop - 10)}px`;
ov.appendChild(line);
ov.appendChild(lbl);
colsWrap.appendChild(ov);
}
}
track.appendChild(colsWrap);
scroll.appendChild(track);
wrap.appendChild(scroll);
iosAgendaMount.innerHTML = "";
iosAgendaMount.appendChild(wrap);
}
function renderMonthInAgenda() {
if (!iosAgendaMount) return;
const y = iosCursor.getFullYear();
const m = iosCursor.getMonth();
const first = new Date(y, m, 1);
const last = new Date(y, m + 1, 0);
let startWeekday = first.getDay() - 1;
if (startWeekday < 0) startWeekday = 6;
const wrap = document.createElement("div");
wrap.className = "ios-month-agenda";
const wk = document.createElement("div");
wk.className = "ios-month-agenda-weekdays";
["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].forEach((t) => {
const s = document.createElement("span");
s.textContent = t;
wk.appendChild(s);
});
wrap.appendChild(wk);
const grid = document.createElement("div");
grid.className = "ios-month-agenda-grid";
for (let i = 0; i < startWeekday; i++) {
const c = document.createElement("div");
c.className = "ios-month-cell ios-month-cell--pad";
grid.appendChild(c);
}
const today = new Date();
for (let d = 1; d <= last.getDate(); d++) {
const cell = document.createElement("button");
cell.type = "button";
cell.className = "ios-month-cell";
const list = eventsForLocalDay(y, m, d);
if (list.length) cell.classList.add("ios-month-cell--has");
if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) {
cell.classList.add("ios-month-cell--today");
}
cell.innerHTML = `<span class="ios-month-num">${d}</span>`;
if (list.length) {
const dots = document.createElement("div");
dots.className = "ios-month-dots";
list.slice(0, 4).forEach(() => {
const dot = document.createElement("span");
dot.className = "ios-month-dot";
dots.appendChild(dot);
});
cell.appendChild(dots);
}
cell.addEventListener("click", () => {
iosViewMode = "day";
iosCursor = new Date(y, m, d);
setActiveSegments();
updateToolbarTitle();
renderAgenda();
});
grid.appendChild(cell);
}
wrap.appendChild(grid);
iosAgendaMount.innerHTML = "";
iosAgendaMount.appendChild(wrap);
}
function renderAgenda() {
updateToolbarTitle();
setActiveSegments();
if (iosViewMode === "month") {
renderMonthInAgenda();
} else {
renderWeekOrDay();
}
}
function startNowTimer() {
if (iosNowTimer) clearInterval(iosNowTimer);
iosNowTimer = setInterval(() => {
if (iosViewMode === "week" || iosViewMode === "day") renderAgenda();
}, 60000);
}
function showListSection(show) {
iosListVisible = !!show;
const sec = document.getElementById("ios-list-section");
if (!sec) return;
sec.style.display = show ? "block" : "none";
if (show && iosAgendaMount) {
iosAgendaMount.innerHTML =
'<p class="ios-agenda-list-hint">Vue liste ci-dessous. Utilise <strong>Jour</strong>, <strong>Semaine</strong> ou <strong>Mois</strong> pour retrouver lagenda.</p>';
} else {
renderAgenda();
}
}
async function loadEvents() {
const events = await iosApi("events");
iosEventsCache = events;
if (iosEventsList) {
iosEventsList.innerHTML = "";
if (!events.length) {
iosEventsList.innerHTML = "<div class='ios-agenda-intro'>Aucun événement.</div>";
} else {
events.forEach((evt) => {
const card = document.createElement("div");
card.className = "ios-event-card";
if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) {
card.style.borderLeft = `4px solid ${evt.display_color}`;
}
card.innerHTML = `
<div class="ios-event-card-head">
<div>
<div class="ios-event-card-title">${escHtml(evt.title || "(Sans titre)")}</div>
<div class="ios-event-card-meta">${escHtml(formatDate(evt.start_at))}${escHtml(formatDate(evt.end_at))}</div>
<div class="ios-event-card-meta">${escHtml(evt.location || "")}</div>
</div>
<div class="ios-event-card-actions">
<button type="button" class="ios-agenda-btn" data-edit="${evt.id}">Modifier</button>
<button type="button" class="ios-agenda-btn" data-delete="${evt.id}">Supprimer</button>
</div>
</div>
`;
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
card.querySelector("[data-delete]").addEventListener("click", async () => {
if (!confirm("Supprimer cet événement ?")) return;
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
await loadEvents();
});
iosEventsList.appendChild(card);
});
}
}
if (!iosListVisible) {
renderAgenda();
}
}
async function loadSyncStatus() {
if (!iosSyncStatus) return;
const status = await iosApi("sync_status");
iosSyncStatus.textContent = status.message;
}
async function loadCalendarPrefs() {
const wrap = document.getElementById("ios-calendar-prefs-rows");
const msg = document.getElementById("ios-calendar-prefs-msg");
if (!wrap) return;
if (msg) msg.textContent = "";
wrap.innerHTML = "<span class=\"ios-agenda-intro\">Chargement des calendriers…</span>";
try {
const data = await iosApi("calendar_sources");
const rows = data.calendars || [];
wrap.innerHTML = "";
if (!rows.length) {
wrap.innerHTML =
"<span class=\"ios-agenda-intro\">Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise.</span>";
return;
}
rows.forEach((row, idx) => {
const id = `ios-cal-cb-${idx}`;
const rowEl = document.createElement("div");
rowEl.className = "ios-cal-pref-row";
rowEl.dataset.urlKey = row.url_key;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.id = id;
cb.checked = row.visible !== false;
const lab = document.createElement("label");
lab.className = "ios-cal-pref-label";
lab.htmlFor = id;
lab.textContent = row.display || row.url_key;
const colInp = document.createElement("input");
colInp.type = "color";
colInp.className = "ios-cal-pref-color";
colInp.value = row.color || suggestedColorForKey(row.url_key);
colInp.title = "Couleur dans lagenda";
const autoBtn = document.createElement("button");
autoBtn.type = "button";
autoBtn.className = "ios-cal-pref-reset";
autoBtn.textContent = "Palette auto";
autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)";
autoBtn.addEventListener("click", () => {
rowEl.dataset.paletteAuto = "1";
colInp.disabled = true;
colInp.style.opacity = "0.35";
});
rowEl.appendChild(cb);
rowEl.appendChild(lab);
rowEl.appendChild(colInp);
rowEl.appendChild(autoBtn);
wrap.appendChild(rowEl);
});
} catch (e) {
wrap.innerHTML = `<span class="ios-agenda-intro">${escHtml(e.message)}</span>`;
}
}
async function saveCalendarPrefs() {
const msg = document.getElementById("ios-calendar-prefs-msg");
const wrap = document.getElementById("ios-calendar-prefs-rows");
if (!wrap) return;
const prefs = {};
wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => {
const key = rowEl.dataset.urlKey;
if (!key) return;
const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true;
const colInp = rowEl.querySelector(".ios-cal-pref-color");
let color = null;
if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) {
const v = colInp.value;
if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) {
color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase();
}
}
prefs[key] = { visible: vis, color };
});
try {
await iosApi("calendar_prefs", { method: "POST", body: { prefs } });
if (msg) msg.textContent = "Préférences enregistrées.";
await loadEvents();
await loadCalendarPrefs();
} catch (e) {
if (msg) msg.textContent = e.message;
}
}
iosForm.addEventListener("submit", async (e) => {
e.preventDefault();
const idStr = document.getElementById("ios-event-id").value.trim();
const payload = {
title: document.getElementById("ios-title").value.trim(),
location: document.getElementById("ios-location").value.trim(),
description: document.getElementById("ios-description").value.trim(),
start_at: localDatetimeToSql(document.getElementById("ios-start").value),
end_at: localDatetimeToSql(document.getElementById("ios-end").value),
};
if (idStr) {
payload.id = parseInt(idStr, 10);
await iosApi("events", { method: "PUT", body: payload });
} else {
await iosApi("events", { method: "POST", body: payload });
}
resetForm();
await loadEvents();
});
document.getElementById("ios-form-reset")?.addEventListener("click", resetForm);
document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
const btn = document.getElementById("ios-sync-btn");
btn.disabled = true;
try {
const data = await iosApi("sync", { method: "POST", body: {} });
if (iosSyncStatus) iosSyncStatus.textContent = data.message;
await loadEvents();
await loadCalendarPrefs();
} finally {
btn.disabled = false;
}
});
document.querySelectorAll(".ios-seg").forEach((btn) => {
btn.addEventListener("click", () => {
iosViewMode = /** @type {'day'|'week'|'month'} */ (btn.getAttribute("data-ios-view"));
showListSection(false);
startNowTimer();
});
});
document.getElementById("ios-view-list")?.addEventListener("click", () => {
showListSection(true);
if (iosNowTimer) clearInterval(iosNowTimer);
});
document.getElementById("ios-agenda-prev")?.addEventListener("click", () => {
if (iosViewMode === "day") iosCursor = addDays(iosCursor, -1);
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, -7);
else iosCursor = addMonths(iosCursor, -1);
renderAgenda();
});
document.getElementById("ios-agenda-next")?.addEventListener("click", () => {
if (iosViewMode === "day") iosCursor = addDays(iosCursor, 1);
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, 7);
else iosCursor = addMonths(iosCursor, 1);
renderAgenda();
});
document.getElementById("ios-agenda-today")?.addEventListener("click", () => {
iosCursor = new Date();
showListSection(false);
startNowTimer();
});
document.getElementById("ios-agenda-add")?.addEventListener("click", () => {
resetForm();
const now = new Date();
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0);
const end = new Date(now.getTime() + 3600000);
const f = (d) => {
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
};
document.getElementById("ios-start").value = f(now);
document.getElementById("ios-end").value = f(end);
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("ios-title")?.focus();
});
document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs());
window.addEventListener("DOMContentLoaded", async () => {
try {
iosCursor = new Date();
await loadEvents();
await loadCalendarPrefs();
await loadSyncStatus();
showListSection(false);
startNowTimer();
} catch (e) {
if (iosSyncStatus) iosSyncStatus.textContent = e.message;
if (iosAgendaMount) iosAgendaMount.innerHTML = `<p class="ios-agenda-intro">${escHtml(e.message)}</p>`;
}
});
+303
View File
@@ -0,0 +1,303 @@
<?php
require_once dirname(__DIR__, 2) . '/includes/crypto.php';
require_once dirname(__DIR__, 2) . '/includes/icloud_caldav.php';
/**
* Mot de passe normalisé + URL calendrier (découverte si racine iCloud).
*
* @return array{integration: array, password: string}
*/
function ios_caldav_prepare(array $integration, ?PDO $metaPdo = null): array
{
$password = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
$url = trim($integration['calendar_url'] ?? '');
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $password, $url);
$out = $integration;
$out['calendar_url'] = $resolvedUrl;
if ($metaPdo && !empty($integration['id']) && rtrim($url, '/') !== rtrim($resolvedUrl, '/')) {
$metaPdo->prepare('UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?')
->execute([$resolvedUrl, $integration['id']]);
}
return ['integration' => $out, 'password' => $password];
}
function ios_make_ics(array $event): string
{
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
$start = gmdate('Ymd\THis\Z', strtotime($event['start_at']));
$end = gmdate('Ymd\THis\Z', strtotime($event['end_at']));
$summary = addcslashes($event['title'] ?? '', ",;");
$description = addcslashes($event['description'] ?? '', ",;");
$location = addcslashes($event['location'] ?? '', ",;");
$updated = gmdate('Ymd\THis\Z');
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//HouseHub//Calendar iOS//FR\r\nBEGIN:VEVENT\r\nUID:$uid\r\nDTSTAMP:$updated\r\nDTSTART:$start\r\nDTEND:$end\r\nSUMMARY:$summary\r\nDESCRIPTION:$description\r\nLOCATION:$location\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
}
function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array
{
$ch = curl_init($url);
$defaultCt = ($method === 'REPORT' || $method === 'PROPFIND')
? 'application/xml; charset=utf-8'
: 'text/calendar; charset=utf-8';
$requestHeaders = array_merge([
'Content-Type: ' . $defaultCt,
], $headers);
$timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $username . ':' . $password,
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false) {
return ['code' => 0, 'headers' => '', 'body' => ''];
}
return [
'code' => $code,
'headers' => substr($raw, 0, $headerSize),
'body' => substr($raw, $headerSize),
];
}
/**
* Déplie les lignes ICS pliées (RFC 5545).
*/
function ios_ics_unfold(string $ics): string
{
$ics = str_replace("\r\n", "\n", $ics);
return preg_replace("/\n[ \t]/", '', $ics);
}
/**
* @return list<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
*/
function ios_parse_vevents_from_ics(string $ics): array
{
$ics = ios_ics_unfold($ics);
$events = [];
if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) {
return [];
}
foreach ($matches[1] as $chunk) {
if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) {
continue;
}
$uid = trim($uidM[1]);
$summary = '';
if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) {
$summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
}
$description = '';
if (preg_match('/^DESCRIPTION:([^\r\n]*)/m', $chunk, $m)) {
$description = str_replace('\\,', ',', str_replace('\\;', ';', str_replace('\\n', "\n", trim($m[1]))));
}
$location = '';
if (preg_match('/^LOCATION:([^\r\n]*)/m', $chunk, $m)) {
$location = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
}
$startRaw = null;
if (preg_match('/^DTSTART[^:]*:([^\r\n]+)/m', $chunk, $m)) {
$startRaw = trim($m[1]);
}
if ($startRaw === null || $startRaw === '') {
continue;
}
$endRaw = null;
if (preg_match('/^DTEND[^:]*:([^\r\n]+)/m', $chunk, $m)) {
$endRaw = trim($m[1]);
}
if (preg_match('/^\d{8}$/', $startRaw)) {
$startTs = strtotime($startRaw . 'T000000 UTC');
} else {
$norm = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $startRaw);
$startTs = strtotime($norm ?: $startRaw);
if ($startTs === false) {
$startTs = strtotime($startRaw);
}
}
if ($startTs === false) {
continue;
}
if ($endRaw !== null && $endRaw !== '') {
if (preg_match('/^\d{8}$/', $endRaw)) {
$endTs = strtotime($endRaw . 'T000000 UTC');
} else {
$normE = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $endRaw);
$endTs = strtotime($normE ?: $endRaw);
if ($endTs === false) {
$endTs = strtotime($endRaw);
}
}
} else {
$endTs = $startTs;
}
if ($endTs === false) {
$endTs = $startTs;
}
$events[] = [
'external_uid' => $uid,
'title' => $summary,
'description' => $description,
'location' => $location,
'start_at' => date('Y-m-d H:i:s', $startTs),
'end_at' => date('Y-m-d H:i:s', $endTs),
];
}
return $events;
}
/**
* Associe chaque fragment iCalendar du REPORT à lURL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud).
*
* @return list<array{resource_url:string,ical:string}>
*/
function ios_caldav_parse_report_calendar_fragments(string $xmlBody, string $calendarBaseUrl): array
{
$dom = new DOMDocument();
if (!@$dom->loadXML($xmlBody)) {
return [];
}
$xpath = new DOMXPath($dom);
$responses = $xpath->query("//*[local-name()='response']");
$out = [];
foreach ($responses as $resp) {
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
if ($hrefNodes->length === 0) {
continue;
}
$href = trim($hrefNodes->item(0)->textContent);
if ($href === '') {
continue;
}
$cd = $xpath->query(".//*[local-name()='calendar-data']", $resp);
if ($cd->length === 0) {
continue;
}
$ical = trim($cd->item(0)->textContent);
if ($ical === '' || stripos($ical, 'BEGIN:VEVENT') === false) {
continue;
}
$resourceUrl = (str_starts_with($href, 'http://') || str_starts_with($href, 'https://'))
? $href
: hh_caldav_resolve_href(rtrim($calendarBaseUrl, '/') . '/', $href);
$out[] = ['resource_url' => $resourceUrl, 'ical' => $ical];
}
return $out;
}
/**
* URLs des collections calendrier à interroger (tous les calendriers iCloud du compte, sinon lURL configurée seule).
*
* @return list<string>
*/
function ios_sync_calendar_collection_urls(array $integration, string $password): array
{
$primary = rtrim($integration['calendar_url'] ?? '', '/') . '/';
if (hh_caldav_url_is_icloud($primary)) {
try {
$entries = hh_icloud_discover_calendar_entries($integration['username'], $password);
return array_values(array_unique(array_map(static function (array $e): string {
return rtrim($e['url'], '/') . '/';
}, $entries)));
} catch (Throwable $e) {
// compte partiellement lisible : au moins le calendrier principal
}
}
return [$primary];
}
/**
* @return list<array<string,mixed>> champs événement + _resource_url + _calendar_collection_url
*/
function ios_fetch_remote_events(array $integration, string $password): array
{
$calUrl = rtrim($integration['calendar_url'], '/') . '/';
$tStart = gmdate('Ymd\THis\Z', strtotime('-3 years'));
$tEnd = gmdate('Ymd\THis\Z', strtotime('+4 years'));
$reportXml = '<?xml version="1.0" encoding="UTF-8"?>'
. '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
. '<D:prop><C:calendar-data/></D:prop>'
. '<C:filter>'
. '<C:comp-filter name="VCALENDAR">'
. '<C:comp-filter name="VEVENT">'
. '<C:time-range start="' . htmlspecialchars($tStart, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '" end="' . htmlspecialchars($tEnd, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '"/>'
. '</C:comp-filter></C:comp-filter></C:filter>'
. '</C:calendar-query>';
$res = ios_caldav_request($calUrl, $integration['username'], $password, 'REPORT', $reportXml, [
'Accept: application/xml, text/xml',
'Depth: 1',
]);
if (!in_array($res['code'], [200, 207], true)) {
throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').');
}
$fragments = ios_caldav_parse_report_calendar_fragments($res['body'], $calUrl);
$byUid = [];
foreach ($fragments as $frag) {
foreach (ios_parse_vevents_from_ics($frag['ical']) as $ev) {
$ev['_resource_url'] = $frag['resource_url'];
$ev['_calendar_collection_url'] = $calUrl;
$byUid[$ev['external_uid']] = $ev;
}
}
if ($byUid === [] && strpos($res['body'], 'multistatus') === false) {
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
if ($get['code'] >= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) {
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
$ev['_resource_url'] = null;
$ev['_calendar_collection_url'] = $calUrl;
$byUid[$ev['external_uid']] = $ev;
}
}
}
return array_values($byUid);
}
/**
* Fusionne tous les calendriers du compte (iCloud) ou une seule collection.
*
* @return list<array<string,mixed>>
*/
function ios_fetch_remote_events_all_calendars(array $integration, string $password): array
{
$urls = ios_sync_calendar_collection_urls($integration, $password);
$merged = [];
foreach ($urls as $url) {
$sub = $integration;
$sub['calendar_url'] = $url;
foreach (ios_fetch_remote_events($sub, $password) as $ev) {
$merged[$ev['external_uid']] = $ev;
}
}
return array_values($merged);
}
function ios_push_event_to_remote(array $integration, array $event, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
{
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($uid) . '.ics');
$ics = ios_make_ics($event);
return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics);
}
function ios_delete_remote_event(array $integration, string $externalUid, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
{
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($externalUid) . '.ics');
return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']);
}
@@ -0,0 +1,157 @@
<?php
// /modules/family-calendar/admin/generate-calendar-year.php
require __DIR__ . '/../../../includes/db.php';
// S'assurer que PDO lève des exceptions en cas d'erreur
if (isset($pdo) && $pdo instanceof PDO) {
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} else {
die("Erreur : PDO non initialisé dans db.php");
}
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
if ($year < 2000 || $year > 2100) {
die("Année invalide.");
}
try {
$pdo->beginTransaction();
// On supprime d'abord les semaines de cette année calendaire
$stmtDel = $pdo->prepare("DELETE FROM pf_calendar_weeks WHERE year = :year");
$stmtDel->execute([':year' => $year]);
// 1er janvier de l'année calendaire
$start = new DateTime("$year-01-01");
// On remonte au lundi précédent (ou on reste dessus si déjà lundi)
while ($start->format('N') != 1) { // 1 = lundi
$start->modify('-1 day');
}
// Dernier jour de l'année
$end = new DateTime("$year-12-31");
// Préparation de l'INSERT avec gestion des doublons
// (en supposant une contrainte UNIQUE, par ex. sur (year, week_iso_year, week_iso_number))
$sql = "
INSERT INTO pf_calendar_weeks (
year, week_iso_year, week_iso_number, week_label,
month, month_name,
week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date, sat_date, sun_date
) VALUES (
:year, :week_iso_year, :week_iso_number, :week_label,
:month, :month_name,
:week_start_date, :mon_date, :tue_date, :wed_date, :thu_date, :fri_date, :sat_date, :sun_date
)
ON DUPLICATE KEY UPDATE
week_label = VALUES(week_label),
month = VALUES(month),
month_name = VALUES(month_name),
week_start_date = VALUES(week_start_date),
mon_date = VALUES(mon_date),
tue_date = VALUES(tue_date),
wed_date = VALUES(wed_date),
thu_date = VALUES(thu_date),
fri_date = VALUES(fri_date),
sat_date = VALUES(sat_date),
sun_date = VALUES(sun_date)
";
$stmt = $pdo->prepare($sql);
// fonction mois FR
function getMonthNameFr($monthIndexZeroBased) {
$months = [
"Janvier", "Fevrier", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Aout", "Septembre", "Octobre", "Novembre", "Decembre",
];
return $months[$monthIndexZeroBased] ?? "";
}
$current = clone $start;
$insertCount = 0;
while ($current <= $end) {
$monday = clone $current;
// Calcul des 7 jours de la semaine
$mon = clone $monday;
$tue = (clone $monday)->modify('+1 day');
$wed = (clone $monday)->modify('+2 days');
$thu = (clone $monday)->modify('+3 days');
$fri = (clone $monday)->modify('+4 days');
$sat = (clone $monday)->modify('+5 days');
$sun = (clone $monday)->modify('+6 days');
// Année / semaine ISO
$weekIsoYear = (int)$monday->format('o'); // année ISO
$weekIsoNumber = (int)$monday->format('W');
$weekLabel = 'W' . str_pad($weekIsoNumber, 2, '0', STR_PAD_LEFT);
// Détermination du mois d'affectation de la semaine
// -> on compte combien de jours de la semaine tombent dans chaque mois
$days = [$mon, $tue, $wed, $thu, $fri, $sat, $sun];
// compteur mois => nb de jours (clé = numéro de mois 1..12)
$monthCounts = [];
foreach ($days as $d) {
$m = (int)$d->format('n'); // 1-12
if (!isset($monthCounts[$m])) {
$monthCounts[$m] = 0;
}
$monthCounts[$m]++;
}
// On prend le mois ayant le plus de jours
// (si égalité, le mois ayant la plus petite valeur numérique gagnera,
// ce qui est raisonnable, mais on peut changer si besoin)
$chosenMonth = null;
$maxDays = -1;
foreach ($monthCounts as $m => $count) {
if ($count > $maxDays) {
$maxDays = $count;
$chosenMonth = $m;
}
}
$month = $chosenMonth;
$monthName = getMonthNameFr($month - 1);
$stmt->execute([
':year' => $weekIsoYear, // <-- au lieu de $year
':week_iso_year' => $weekIsoYear,
':week_iso_number' => $weekIsoNumber,
':week_label' => $weekLabel,
':month' => $month,
':month_name' => $monthName,
':week_start_date' => $monday->format('Y-m-d'),
':mon_date' => $mon->format('Y-m-d'),
':tue_date' => $tue->format('Y-m-d'),
':wed_date' => $wed->format('Y-m-d'),
':thu_date' => $thu->format('Y-m-d'),
':fri_date' => $fri->format('Y-m-d'),
':sat_date' => $sat->format('Y-m-d'),
':sun_date' => $sun->format('Y-m-d'),
]);
$insertCount++;
$current->modify('+7 days');
}
$pdo->commit();
echo "Calendrier $year généré. Semaines traitées : " . $insertCount;
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
die("Erreur PDO : " . $e->getMessage());
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
die("Erreur générale : " . $e->getMessage());
}
+125
View File
@@ -0,0 +1,125 @@
<?xml version="1.0" ?><svg id="Layer_1" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#C60C30;}
.st2{fill:#003478;}
.st3{fill:#FF9933;}
.st4{fill:#128807;}
.st5{fill:#000088;}
.st6{fill:#032774;}
.st7{fill:#DF0333;}
.st8{fill:#EE2B2C;}
.st9{fill:#032767;}
.st10{fill:#006747;}
.st11{fill:#CD1225;}
.st12{fill:#FBC942;}
.st13{fill:#BD9B37;}
.st14{fill:#9BA068;}
.st15{fill:#707633;}
.st16{fill:#006748;}
.st17{fill:#CC2029;}
.st18{fill:#30C1DB;}
.st19{fill:#F7C740;}
.st20{fill:#0C72A6;}
.st21{fill:#F8A952;}
.st22{fill:#943120;}
.st23{fill:#231F20;}
.st24{fill:#0B8388;}
.st25{fill:#8BBDBE;}
.st26{fill:#05534E;}
.st27{fill:none;stroke:#39B54A;stroke-width:0.25;stroke-miterlimit:10;}
.st28{fill:none;stroke:#00A651;stroke-miterlimit:10;}
.st29{fill:none;stroke:#00A651;stroke-width:0.5;stroke-miterlimit:10;}
.st30{fill:url(#SVGID_1_);}
.st31{fill:url(#SVGID_2_);}
.st32{fill:url(#SVGID_3_);}
.st33{fill:url(#SVGID_4_);}
.st34{fill:url(#SVGID_5_);}
.st35{fill:url(#SVGID_6_);}
.st36{fill:url(#SVGID_7_);}
.st37{fill:url(#SVGID_8_);}
.st38{fill:url(#SVGID_9_);}
.st39{fill:url(#SVGID_10_);}
.st40{fill:url(#SVGID_11_);}
.st41{fill:url(#SVGID_12_);}
.st42{fill:url(#SVGID_13_);}
.st43{fill:url(#SVGID_14_);}
.st44{fill:url(#SVGID_15_);}
.st45{fill:#F8C73E;}
.st46{fill:#77722F;}
.st47{fill:#A7AB71;}
.st48{fill:#949457;}
.st49{fill:#AE6F2A;}
.st50{fill:#FFF200;}
.st51{fill:#C5C6A6;}
.st52{fill:#A3A395;}
.st53{fill:#4C2915;}
.st54{fill:#8E4520;}
.st55{fill:#D1A467;}
.st56{fill:#AA6C2A;}
.st57{fill:#DAAC6D;}
.st58{fill:#8F4620;}
.st59{fill:#BC7E2F;}
.st60{fill:#E6E7E8;}
.st61{fill:#F06D19;}
.st62{fill:#19B43D;}
.st63{fill:#07A3DC;}
.st64{fill:#FBD023;}
.st65{fill:#EC2938;}
.st66{fill:#F9DF47;}
.st67{fill:#0967C4;}
.st68{fill:#C5A310;}
.st69{clip-path:url(#SVGID_17_);}
.st70{fill:#6FD7F2;}
.st71{clip-path:url(#SVGID_19_);}
.st72{fill:#FF0303;}
.st73{fill:#FF6407;}
.st74{fill:#FDFE1F;}
.st75{fill:#00FE1E;}
.st76{fill:#08CCFE;}
.st77{fill:#120FFD;}
.st78{fill:#810480;}
.st79{fill:#0642A1;}
.st80{fill:#02275F;}
.st81{fill:#80A0D1;}
.st82{fill:#FB3B3A;}
.st83{fill:#FF0302;}
.st84{fill:#520200;}
.st85{fill:#940101;}
.st86{clip-path:url(#SVGID_21_);}
.st87{fill:#91BB35;}
.st88{fill:#ECE62B;}
.st89{fill:#C5CA2C;}
.st90{fill:#95C82B;}
.st91{fill:#009D4A;}
.st92{fill:#BF0A30;}
.st93{fill:#002868;}
.st94{fill:#028832;}
.st95{fill:#FBDC1D;}
.st96{fill:#D91219;}
.st97{fill:#0738A6;}
.st98{fill:#FD0302;}
.st99{fill:#060593;}
.st100{fill:#115EAD;}
.st101{fill:#C50C1E;}
.st102{fill:#FEC315;}
.st103{fill:#C7B011;}
.st104{fill:#AC1518;}
.st105{fill:#028E6E;}
.st106{fill:#CBCBCB;}
.st107{fill:#095BBD;}
.st108{fill:#C7B013;}
.st109{fill:#00AEEF;}
.st110{fill:#C7B112;}
.st111{fill:#FED592;}
.st112{fill:#028D6E;}
.st113{fill:#C7B012;}
.st114{fill:#C6B011;}
.st115{fill:#103BEE;}
.st116{fill:#DB4445;}
.st117{fill:#EC72A9;}
.st118{fill:#006133;}
.st119{fill:#D11133;}
.st120{fill:#042B7D;}
.st121{fill:#FEC527;}
.st122{fill:#BF920D;}
</style><g><path class="st96" d="M510.4,284.4c-0.3,2.6-0.7,5.2-1,7.8c-0.2,1-0.3,2.1-0.5,3.2c-0.5,3.2-1.1,6.4-1.7,9.7 c-0.1,0.5-0.2,1-0.3,1.5v0.1c-0.1,0.5-0.2,1-0.2,1c-2.5,11.9-5.6,22.9-9.4,33.7H14.6c-3.4-9.8-6.3-19.8-8.6-30.1 c-0.2-0.5-0.3-1-0.4-1.5c-0.1-0.5-0.2-1-0.3-1.4c-1.6-7.9-2.9-15.8-3.7-23.9H510.4z"/><path class="st95" d="M497.4,341.3c-5.3,14.9-11.9,29.1-19.7,42.6c-2.8,4.9-5.8,9.6-8.9,14.3H43.1c-3.1-4.7-6-9.4-8.8-14.3 c-7.8-13.4-14.4-27.8-19.7-42.6H497.4z"/><path class="st96" d="M468.8,398.2c-4.1,6.1-8.4,12.1-13,17.8c-11.5,14.4-24.5,27.5-38.8,39.1H95c-14.3-11.6-27.4-24.7-38.8-39.1 c-4.6-5.7-9-11.7-13.1-17.8H468.8z"/><path class="st95" d="M417,455.1C372.9,490.7,317,512,256,512c-61,0-117-21.3-161-56.9H417z"/><path class="st96" d="M468.8,113.8H43.2c4.1-6.1,8.4-12.1,13-17.8c11.6-14.4,24.7-27.6,39-39.1h321.7c14.4,11.6,27.4,24.7,39,39.1 C460.4,101.7,464.8,107.6,468.8,113.8z"/><path class="st95" d="M416.8,56.9H95.1C139.1,21.3,195,0,256,0C316.9,0,372.9,21.3,416.8,56.9z"/><path class="st95" d="M497.4,170.7H14.6c5.2-14.9,11.8-29.2,19.7-42.7c2.8-4.8,5.7-9.6,8.9-14.2h425.7c3.1,4.6,6.1,9.4,8.9,14.2 C485.5,141.5,492.1,155.7,497.4,170.7z"/><path class="st96" d="M510.4,227.6H1.6c0,0,0.1-0.3,0.1-0.5V227l0.1-0.6c2.2-19.3,6.6-37.9,12.9-55.7h482.8 c3.2,8.8,5.7,17.9,7.9,27.1c0.1,0.2,0.1,0.5,0.2,0.7c0,0,0.1,0.5,0.2,0.7c1.3,5.9,2.4,11.8,3.4,17.8c0,0,0,0.4,0.1,0.6 c0,0,0.1,0.4,0.1,0.6C509.6,221.3,510.1,224.4,510.4,227.6z"/><path class="st95" d="M512,256c0,1.3,0,2.5-0.1,3.8c0,0.3,0,0.6,0,0.8v0.9l-0.1,0.3c0,0,0,0.4,0,0.6c-0.2,7.4-0.7,14.8-1.4,22.1 H1.6c-0.2-1.6-0.3-3-0.5-4.5c-0.1-0.9-0.2-1.7-0.2-2.5l0,0c-0.5-4.9-0.7-10-0.9-15.1c0-0.2,0-0.4,0-0.4c0-0.4,0-0.6,0-0.6 C0,260,0,258.8,0,257.5c0,0,0-0.3,0-0.6V256c0-1.7,0-3.5,0.1-5.2c0-0.2,0-0.3,0-0.3c0-0.4,0-0.6,0-0.6c0.1-5.3,0.4-10.5,0.9-15.6 c0-0.2,0.1-0.5,0.1-0.5c0-0.5,0.1-0.7,0.1-0.9c0.2-1.7,0.3-3.4,0.5-5.1v-0.2h508.8c0.8,7.2,1.3,14.6,1.5,22.1c0,0,0.1,0.4,0,0.6 c0,0,0.1,0.4,0.1,0.6C511.9,252.5,512,254.3,512,256z"/></g></svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+957
View File
@@ -0,0 +1,957 @@
/* modules/family-calendar/family-calendar.css */
:root {
/* Couleurs sémantiques Calendrier */
--c-school-holiday: #ede9fe;
--c-public-holiday: repeating-linear-gradient(
45deg,
#f8fafc,
#f8fafc 8px,
#f1f5f9 8px,
#f1f5f9 16px
);
--c-off-carole: #fef3c7;
--c-extra-off: #fee2e2;
--c-selected: #dbeafe;
/* Thèmes Parents (Sync Budget) */
--bg-alex: #ecfeff;
--text-alex: #0891b2;
--bg-laia: #fffbeb;
--text-laia: #d97706;
/* Rappel des variables Global Design System pour cohérence */
--pf-primary: #3b82f6;
--pf-border: #e2e8f0;
--pf-bg-lighter: #f8fafc;
--pf-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.pf-family-calendar.pf-main {
padding-top: 20px;
padding-bottom: 40px;
}
/* En-tête & Boutons */
.fc-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.pf-btn-icon-text {
background: white;
border: 1px solid var(--pf-border);
border-radius: 50px;
padding: 10px 20px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
font-weight: 600;
color: #475569;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--pf-shadow-sm);
}
.pf-btn-icon-text:hover {
border-color: var(--pf-primary);
color: var(--pf-primary);
background: #f8fafc;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Calendrier Mensuel */
.fc-month-calendar-wrapper {
background: white;
border: 1px solid var(--pf-border);
border-radius: 16px;
padding: 24px;
margin-bottom: 32px;
box-shadow: var(--pf-shadow-sm);
}
.fc-month-header {
display: flex !important;
flex-direction: column !important;
gap: 16px !important;
margin-bottom: 20px !important;
}
.fc-month-nav-row {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
width: 100% !important;
}
.fc-month-nav-row h3 {
margin: 0 !important;
font-size: 1.5rem !important;
font-weight: 800 !important;
color: #0f172a;
text-transform: capitalize !important;
text-align: center !important;
flex-grow: 1 !important;
}
.fc-nav-button {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--pf-border);
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.2s;
color: #64748b;
}
.fc-nav-button:hover {
border-color: var(--pf-primary);
color: var(--pf-primary);
background: #eff6ff;
}
.fc-view-controls {
display: flex !important;
width: 100% !important;
background: #f1f5f9 !important;
padding: 4px !important;
border-radius: 12px !important;
}
.fc-view-button {
flex: 1 !important;
text-align: center !important;
background: transparent;
border: none;
border-radius: 6px;
padding: 6px 16px;
font-size: 0.85rem;
font-weight: 600;
color: var(--pf-text-muted);
cursor: pointer;
transition: 0.2s;
}
.fc-view-button--active {
background: white !important;
color: var(--pf-primary) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
font-weight: 700;
}
.fc-month-calendar {
width: 100%;
display: block;
overflow-x: auto;
}
.fc-month-table {
min-width: 100% !important;
width: 100% !important;
table-layout: fixed;
border-collapse: separate !important;
border-spacing: 0;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--pf-border) !important;
}
.fc-month-table th {
padding: 10px !important;
font-size: 0.8rem !important;
background: #f8fafc !important;
color: #64748b !important;
font-weight: 700 !important;
text-transform: uppercase;
border-bottom: 1px solid var(--pf-border) !important;
border-right: 1px solid var(--pf-border) !important;
}
.fc-month-table td {
height: 80px;
padding: 6px !important;
font-size: 0.85rem;
vertical-align: top;
position: relative;
cursor: pointer;
border-right: 1px solid var(--pf-border) !important;
border-bottom: 1px solid var(--pf-border) !important;
transition: background 0.1s;
}
.fc-month-table td:hover:not(.fc-day--other-month) {
background: #f1f5f9;
}
.fc-day--other-month {
background: #fcfcfc !important;
}
/* Vues Multi-mois */
.fc-two-months-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
width: 100%;
overflow-x: auto;
}
.fc-three-months-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
width: 100%;
overflow-x: auto;
}
.fc-month-title {
text-align: center;
font-weight: 700;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--pf-primary);
text-transform: capitalize;
}
/* Décompte Congés (Pills) */
.fc-month-balances {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
margin-top: 20px;
padding-top: 15px;
border-top: 1px dashed var(--pf-border);
}
.fc-minimal-balance-card {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 8px 16px;
border-radius: 50px;
border: 1px solid var(--pf-border);
box-shadow: var(--pf-shadow-sm);
}
.fc-minimal-balance-card strong.alex {
color: var(--text-alex);
}
.fc-minimal-balance-card strong.laia {
color: var(--text-laia);
}
.fc-minimal-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.fc-min-chip {
display: flex;
align-items: center;
background: white;
border-radius: 20px;
padding: 3px 10px;
font-size: 0.75rem;
border: 1px solid var(--pf-border);
font-weight: 600;
}
.fc-min-chip .type {
font-weight: 700;
color: var(--pf-text-muted);
margin-right: 6px;
}
.fc-min-chip .val {
font-weight: 800;
color: var(--pf-text-main);
}
.fc-used-badge {
margin-left: 6px;
background: #fee2e2;
color: var(--pf-danger);
padding: 1px 5px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: bold;
}
.fc-burn-alert {
margin-left: 5px;
cursor: help;
animation: pulseBurn 2s infinite;
font-size: 0.9rem;
display: flex;
align-items: center;
color: var(--pf-danger);
font-weight: bold;
}
@keyframes pulseBurn {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.8;
}
}
/* Couleurs des jours */
.fc-day--school-holiday {
background: var(--c-school-holiday) !important;
}
.fc-day--public-holiday {
background: var(--c-public-holiday) !important;
font-weight: 700;
}
.fc-day--off-carole {
background: var(--c-off-carole) !important;
}
.fc-day--extra-off-carole {
background: var(--c-extra-off) !important;
}
.fc-day--selected {
background: var(--c-selected) !important;
outline: 2px solid var(--pf-primary);
z-index: 5;
}
.fc-day--centre::after {
content: "🏫";
position: absolute;
top: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--avis::after {
content: "";
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
background: url("/modules/family-calendar/assets/img/avis.svg") no-repeat
center;
background-size: contain;
}
.fc-pep-sick-emoji {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 12px;
}
.fc-day--off-carole.fc-day--school-holiday,
.fc-day--extra-off-carole.fc-day--school-holiday,
.fc-day--centre.fc-day--school-holiday,
.fc-day--avis.fc-day--school-holiday {
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
}
/* Planning Hebo (Desktop) */
.fc-week-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
}
.fc-week-nav-controls {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 4px 12px;
border-radius: 50px;
}
#fc-current-school-year-label {
font-size: 1rem;
font-weight: 800;
min-width: 120px;
text-align: center;
}
#planningTable-wrapper {
max-height: 80vh;
overflow: auto;
border: 1px solid var(--pf-border);
border-radius: 16px;
background: white;
box-shadow: var(--pf-shadow-sm);
position: relative;
margin-bottom: 24px;
}
#planningTable {
width: 100%;
min-width: 1000px;
border-collapse: separate;
border-spacing: 0;
}
/* =========================================================
HARMONISATION DES BORDURES ET HEADERS STICKY (1px strict)
========================================================= */
#planningTable thead tr {
height: 30px; /* On fixe la hauteur pour un calcul parfait */
}
#planningTable thead th {
position: sticky;
background: #f8fafc !important;
border: none !important; /* On supprime les bordures natives */
/* On utilise UNIQUEMENT des ombres internes pour simuler 1px de bordure sans doublement */
box-shadow:
inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border) !important;
padding: 6px;
font-size: 0.75rem;
z-index: 20;
color: #475569;
background-clip: padding-box;
}
#planningTable thead tr:nth-child(1) th {
top: 0;
z-index: 22; /* Au premier plan */
}
#planningTable thead tr:nth-child(2) th {
top: 30px; /* Exactement la hauteur de la ligne 1 */
z-index: 21;
}
#planningTable thead tr:nth-child(3) th {
top: 60px; /* Ligne 1 + Ligne 2 */
z-index: 20;
}
.col-alex.header-group,
.col-alex-sub {
background: var(--bg-alex) !important;
color: var(--text-alex) !important;
}
.col-laia.header-group,
.col-laia-sub {
background: var(--bg-laia) !important;
color: var(--text-laia) !important;
}
/* Cellules standards du corps */
#planningTable tbody td {
position: relative;
border: none !important; /* Reset de sécurité */
border-right: 1px solid var(--pf-border) !important;
border-bottom: 1px solid var(--pf-border) !important;
height: 36px;
padding: 0 4px;
text-align: center;
font-size: 0.85rem;
white-space: nowrap;
cursor: pointer;
}
/* Dimensions des colonnes */
.col-month {
width: 54px !important;
min-width: 54px !important;
max-width: 54px !important;
background: white;
text-align: center;
vertical-align: middle;
}
th.col-day,
td.col-day {
min-width: 35px !important;
}
th.col-total,
td.col-total,
th.col-alex-sub,
td.col-alex-sub,
th.col-laia-sub,
td.col-laia-sub {
width: 32px !important;
min-width: 32px !important;
max-width: 32px !important;
}
.rotated-text span {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.7rem;
color: var(--pf-text-muted);
}
/* =========================================================
GESTION DES COLONNES STICKY (Mois & Semaine)
========================================================= */
#planningTable tbody td.col-sticky-mois {
position: sticky !important;
left: 0 !important;
z-index: 15 !important;
background: white !important;
padding: 0 !important;
}
#planningTable tbody td.col-sticky-mois .fc-sticky-mois-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
writing-mode: vertical-rl;
letter-spacing: 1px;
text-align: center;
}
/* 2. Sticky Semaine (Body) */
#planningTable tbody td.col-sticky-sem {
position: sticky !important;
left: 54px !important;
z-index: 14 !important;
background: white !important;
/* Ombre portée pour délimiter la zone sticky du reste du tableau */
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1);
}
/* 3. Sticky Mois (Header) */
#planningTable thead tr th.col-sticky-mois {
position: sticky !important;
top: 0 !important;
left: 0 !important;
z-index: 30 !important;
background: var(--pf-bg-lighter) !important;
/* Les bordures sont gérées par le box-shadow général du thead th */
}
/* 4. Sticky Semaine (Header) */
#planningTable thead tr th.col-sticky-sem {
position: sticky !important;
top: 0 !important;
left: 54px !important;
z-index: 29 !important;
background: var(--pf-bg-lighter) !important;
/* Ombre interne (bordures) + Ombre portée (effet sticky) */
box-shadow:
inset 0 -1px 0 var(--pf-border),
inset -1px 0 0 var(--pf-border),
4px 0 6px -2px rgba(0, 0, 0, 0.1) !important;
}
/* Rétablissement horizontal pour les headers mois et semaine */
#planningTable thead th.col-month,
#planningTable tbody td.col-sticky-sem {
writing-mode: horizontal-tb;
transform: none;
padding: 6px !important;
text-align: center;
}
/* Panneaux bas & Légendes */
.fc-bottom-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
/* Nouvelles bordures style "Budget" pour les conteneurs */
.pf-card {
background: white;
border: 1px solid var(--pf-border);
border-radius: 16px;
box-shadow: var(--pf-shadow-sm);
padding: 24px;
}
.pf-card-title {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.25rem;
color: #0f172a;
border-bottom: 1px solid var(--pf-border);
padding-bottom: 12px;
}
.pf-legend-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pf-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
padding: 6px 10px;
background: #f8fafc;
border: 1px solid var(--pf-border);
border-radius: 8px;
font-weight: 500;
color: #475569;
}
.pf-legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
}
.fc-legend-school-holiday {
background: var(--c-school-holiday);
}
.fc-legend-public-holiday {
background: var(--c-public-holiday);
}
.fc-legend-off-carole {
background: var(--c-off-carole);
}
.fc-legend-extra-off-carole {
background: var(--c-extra-off);
}
.fc-legend-centre::after {
content: "🏫";
}
.fc-legend-pep-sick::after {
content: "🤒";
}
.fc-legend-avis {
background: white url("/modules/family-calendar/assets/img/avis.svg")
no-repeat center;
background-size: contain;
border: none;
}
.fc-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--pf-border);
}
.fc-summ-select {
border: 1px solid var(--pf-border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8rem;
height: auto;
background: #f8fafc;
}
.fc-summary-item {
display: flex;
justify-content: space-between;
padding: 12px 4px;
border-bottom: 1px solid var(--pf-border); /* Ligne de séparation visible */
}
.fc-summary-item:last-child {
border-bottom: none;
}
.fc-summary-label {
font-size: 0.85rem;
color: #64748b;
font-weight: 600;
}
.fc-summary-value {
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
/* Menu Contextuel Tactile */
.fc-selection-menu {
position: absolute;
z-index: 9000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow: var(--pf-shadow-lg);
border-radius: 16px;
padding: 10px 12px;
min-width: 220px;
display: none;
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: top center;
}
@keyframes menuPopIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.fc-menu-section {
padding: 6px 0;
border-bottom: 1px dashed #cbd5e1;
}
.fc-menu-section:last-child {
border: none;
padding-bottom: 0;
}
.fc-menu-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.fc-menu-section strong {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--pf-text-muted);
}
.fc-menu-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.fc-menu-btn {
background: white;
border: 1px solid var(--pf-border);
padding: 6px 8px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
width: 100%;
transition: 0.2s;
box-shadow: var(--pf-shadow-sm);
}
.fc-menu-btn:hover {
background: var(--pf-bg-page);
border-color: var(--pf-primary);
color: var(--pf-primary);
}
.fc-menu-clear-icon {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
display: flex;
border-radius: 6px;
}
.fc-menu-clear-icon svg {
width: 14px;
height: 14px;
}
.fc-menu-clear-icon:hover {
background: #fef2f2;
color: var(--pf-danger);
}
.fc-menu-leaves-table table {
width: 100%;
border-spacing: 2px;
}
.fc-menu-leaves-table th {
font-size: 0.7rem;
color: var(--pf-text-muted);
text-align: center;
}
.fc-th-inline {
display: flex;
justify-content: center;
gap: 4px;
}
/* --- BOUTON DE MENU ACTIF (Élément déjà enregistré) --- */
.fc-menu-btn--active {
background: #eff6ff;
border-color: var(--pf-primary);
color: var(--pf-primary);
/* Un léger inset shadow pour rendre la bordure un peu plus épaisse sans casser la taille */
box-shadow: inset 0 0 0 1px var(--pf-primary);
}
/* Table specifique modale vacances */
.fc-holidays-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.fc-holidays-table th {
background: #f8fafc;
padding: 12px 16px;
text-align: left;
font-weight: 600;
position: sticky;
top: 0;
border-bottom: 2px solid var(--pf-border);
}
.fc-holidays-table td {
padding: 10px 16px;
border-bottom: 1px solid var(--pf-border);
}
.fc-holidays-table tr:nth-child(even) {
background: #fafafa;
}
/* === MODALES : Alignement du Header et de la Croix === */
.pf-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
border-bottom: 1px solid var(--pf-border);
padding-bottom: 15px;
}
.pf-modal-header .pf-modal-title {
margin: 0;
padding: 0;
border-bottom: none; /* On annule la bordure par défaut du global.css pour la déléguer au header */
flex-grow: 1;
}
.pf-modal-close {
background: none;
border: none;
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
color: #94a3b8;
padding: 0 0 0 15px;
transition: color 0.2s;
}
.pf-modal-close:hover {
color: #ef4444; /* Devient rouge au survol */
}
/* =========================================================
SMART DATE SELECTOR
========================================================= */
.fc-smart-select {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-family: inherit;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
/* Padding standard */
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: capitalize;
box-shadow: var(--pf-shadow-sm);
}
.fc-smart-select:hover {
border-color: #94a3b8;
}
.fc-smart-select:focus {
background: #ffffff;
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* --- BOUTON AUJOURD'HUI & CONTROLES --- */
.fc-view-controls {
align-items: center; /* Pour bien centrer le nouveau bouton */
}
.fc-today-button {
background: white;
border: 1px solid var(--pf-border);
border-radius: 8px;
padding: 6px 12px;
font-size: 0.85rem;
font-weight: 700;
color: var(--pf-primary);
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--pf-shadow-sm);
}
.fc-today-button:hover {
background: #eff6ff;
border-color: var(--pf-primary);
}
.fc-view-divider {
width: 1px;
height: 20px;
background: #cbd5e1;
margin: 0 8px;
}
/* --- MISE EN ÉVIDENCE DU JOUR EN COURS --- */
.fc-day--today {
background: #eff6ff !important;
box-shadow: inset 0 0 0 1px #bfdbfe !important;
}
.fc-day--today .fc-day-number {
display: inline;
background: none;
width: auto;
height: auto;
box-shadow: none;
border-radius: 0;
margin: 0;
color: var(--pf-primary);
font-weight: 900;
font-size: 1.1em;
}
@media (max-width: 768px) {
.fc-smart-select {
font-size: 1rem;
padding: 6px 8px;
}
}
/* Mobile */
@media (max-width: 768px) {
.pf-family-calendar .pf-container {
padding: 0 !important;
}
.fc-header-row {
flex-direction: column;
gap: 15px;
padding: 0 10px;
}
.fc-header-row > div {
flex-direction: column;
width: 100%;
}
.pf-btn-icon-text {
width: 100%;
justify-content: center;
}
.fc-week-header,
#planningTable-wrapper {
display: none !important;
}
.fc-month-calendar-wrapper {
padding: 16px !important;
margin-bottom: 20px !important;
}
.fc-month-table td {
height: 46px !important;
padding: 2px !important;
font-size: 0.75rem !important;
}
.fc-bottom-grid {
grid-template-columns: 1fr;
padding: 10px;
}
.fc-selection-menu {
position: fixed !important;
top: auto !important;
left: 0 !important;
bottom: 0 !important;
width: 100% !important;
border-radius: 24px 24px 0 0;
padding: 24px 20px;
transform: translateY(100%);
animation: slideUpSheet 0.3s forwards;
z-index: 10000 !important;
}
.fc-selection-menu::before {
content: "";
display: block;
width: 40px;
height: 5px;
background: #cbd5e1;
border-radius: 10px;
margin: 0 auto 15px auto;
}
.fc-two-months-container,
.fc-three-months-container {
display: flex !important;
flex-direction: column !important;
gap: 20px !important;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
/**
* Paramètre: school_year_start (optionnel)
* - année de début d'année scolaire (ex: 2025 pour 09/2025 -> 08/2026)
* - défaut: année courante si non fourni
*/
$schoolYearStart = isset($_GET['school_year_start'])
? (int)$_GET['school_year_start']
: (int)date('Y');
if ($schoolYearStart < 2000 || $schoolYearStart > 2100) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Année scolaire invalide.']);
exit;
}
$yearStart = $schoolYearStart;
$yearEnd = $schoolYearStart + 1;
// On veut toutes les semaines:
// - de septembre (month >= 9) de yearStart
// - à août (month <= 8) de yearEnd
try {
$sql = "
SELECT
year,
week_iso_year,
week_iso_number,
week_label,
month,
month_name,
week_start_date,
mon_date,
tue_date,
wed_date,
thu_date,
fri_date
FROM pf_calendar_weeks
WHERE
(year = :year_start AND month >= 9)
OR (year = :year_end AND month <= 8)
ORDER BY week_start_date ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':year_start' => $yearStart,
':year_end' => $yearEnd,
]);
$weeks = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'status' => 'success',
'school_year' => $schoolYearStart,
'weeks' => $weeks,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
@@ -0,0 +1,36 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
try {
$stmt = $pdo->prepare("
SELECT
year,
week_iso_year,
week_iso_number,
week_label,
month,
month_name,
week_start_date,
mon_date,
tue_date,
wed_date,
thu_date,
fri_date
FROM pf_calendar_weeks
WHERE year = :year
ORDER BY week_start_date ASC
");
$stmt->execute([':year' => $year]);
$weeks = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['weeks' => $weeks]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
@@ -1,7 +1,7 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../includes/db.php';
require __DIR__ . '/../../../../includes/db.php';
try {
$stmt = $pdo->query("SELECT id, event_date AS date, event_type AS type, person_id, duration FROM pf_events ORDER BY event_date");
@@ -0,0 +1,19 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
try {
$stmt = $pdo->query("
SELECT person_id, leave_type, initial_balance, balance_year
FROM pf_leave_balances
");
$balances = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['balances' => $balances]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
@@ -0,0 +1,20 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
try {
$stmt = $pdo->query("
SELECT person_id, leave_type, snapshot_date, remaining_balance
FROM pf_leave_snapshots
ORDER BY snapshot_date ASC
");
$snapshots = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['snapshots' => $snapshots]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => $e->getMessage(),
]);
}
@@ -0,0 +1,31 @@
<?php
// api/get-leaves.php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
try {
$stmt = $pdo->query("
SELECT
l.id,
l.person_id,
p.name AS person_name,
l.leave_type,
l.leave_date,
l.duration
FROM pf_leaves l
JOIN pf_people p ON p.id = l.person_id
ORDER BY l.leave_date, l.person_id
");
$leaves = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'status' => 'success',
'leaves' => $leaves,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
@@ -0,0 +1,24 @@
<?php
// /modules/family-calendar/includes/api/get-person-leave-meta.php
require __DIR__ . '/../../../../includes/db.php';
header('Content-Type: application/json; charset=utf-8');
try {
$stmt = $pdo->query("
SELECT person_id, anniversary_date
FROM pf_person_leave_meta
");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'meta' => $rows,
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'error' => true,
'message' => $e->getMessage(),
]);
}
@@ -0,0 +1,101 @@
<?php
// modules/family-calendar/includes/api/manage-event.php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
if (!$action) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action manquante.']);
exit;
}
try {
// --- SUPPRESSION UNITAIRE ---
if ($action === 'delete') {
$eventId = (int)($input['event_id'] ?? 0);
if ($eventId <= 0) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'ID manquant.']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
$stmt->execute([$eventId]);
echo json_encode(['status' => 'success']);
exit;
}
// --- MISE À JOUR UNITAIRE ---
if ($action === 'update') {
$eventId = (int)($input['event_id'] ?? 0);
$newType = $input['new_type'] ?? '';
if ($eventId <= 0 || !$newType) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']);
exit;
}
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
$stmt->execute([$newType, $eventId]);
echo json_encode(['status' => 'success']);
exit;
}
// --- SUPPRESSION DE MASSE (Par date et type) ---
// Utilisé quand on ajoute un événement pour nettoyer les doublons potentiels (ex: Off vs Extra)
if ($action === 'bulk_delete_day_types') {
$dates = $input['dates'] ?? [];
$types = $input['types'] ?? [];
$dates = array_filter($dates, function($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
if (empty($dates) || empty($types)) {
echo json_encode(['status' => 'success']);
exit;
}
// Création des placeholders IN (?,?,?)
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
$typePlaceholders = implode(',', array_fill(0, count($types), '?'));
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
$stmt = $pdo->prepare($sql);
// Fusion des tableaux pour l'exécution
$stmt->execute(array_merge($dates, $types));
echo json_encode(['status' => 'success']);
exit;
}
// --- SUPPRESSION TOTALE SUR DES DATES ---
if ($action === 'bulk_delete_all') {
$dates = $input['dates'] ?? [];
$dates = array_filter($dates, function($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
});
if (empty($dates) || empty($types)) {
echo json_encode(['status' => 'success']);
exit;
}
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($dates);
echo json_encode(['status' => 'success']);
exit;
}
// Action inconnue
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action non reconnue : ' . $action]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
@@ -0,0 +1,139 @@
<?php
// api/manage-leaf.php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'JSON invalide.']);
exit;
}
$action = $input['action'] ?? null;
if (!$action) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action manquante.']);
exit;
}
try {
// ===================== DELETE TOUS LES LEAVES D'UN JOUR (EXISTANT) =====================
if ($action === 'delete_day') {
if (empty($input['date'])) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Date manquante pour delete_day.']);
exit;
}
$dateObj = date_create($input['date']);
if (!$dateObj) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Date invalide.']);
exit;
}
$leaveDate = $dateObj->format('Y-m-d');
$stmt = $pdo->prepare("DELETE FROM pf_leaves WHERE leave_date = ?");
$stmt->execute([$leaveDate]);
echo json_encode(['status' => 'success', 'message' => 'Congés supprimés pour ce jour.']);
exit;
}
// ===================== DELETE POUR UNE PERSONNE / JOUR (SINGLE) =====================
if ($action === 'delete_day_person') {
$date = $input['date'] ?? null;
$personId = $input['person_id'] ?? null;
if (empty($date) || empty($personId)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'date ou person_id manquant pour delete_day_person.']);
exit;
}
$dateObj = date_create($date);
if (!$dateObj) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Date invalide pour delete_day_person.']);
exit;
}
$leaveDate = $dateObj->format('Y-m-d');
$stmt = $pdo->prepare("
DELETE FROM pf_leaves
WHERE leave_date = :date
AND person_id = :person_id
");
$stmt->execute([
':date' => $leaveDate,
':person_id' => $personId,
]);
echo json_encode([
'status' => 'success',
'message' => 'Congés supprimés pour cette personne et ce jour.',
'deleted' => $stmt->rowCount(),
]);
exit;
}
// ===================== BULK DELETE POUR UNE PERSONNE / PLUSIEURS JOURS =====================
if ($action === 'bulk_delete_day_person') {
$dates = $input['dates'] ?? null;
$personId = $input['person_id'] ?? null;
if (!is_array($dates) || empty($dates) || empty($personId)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'dates ou person_id manquant pour bulk_delete_day_person.']);
exit;
}
// Normaliser les dates en 'Y-m-d'
$normalizedDates = [];
foreach ($dates as $d) {
$dateObj = date_create($d);
if ($dateObj) {
$normalizedDates[] = $dateObj->format('Y-m-d');
}
}
if (empty($normalizedDates)) {
echo json_encode([
'status' => 'success',
'message' => 'Aucune date valide fournie.',
'deleted' => 0,
]);
exit;
}
// Construire le IN (...)
$placeholders = implode(',', array_fill(0, count($normalizedDates), '?'));
$sql = "
DELETE FROM pf_leaves
WHERE person_id = ?
AND leave_date IN ($placeholders)
";
$stmt = $pdo->prepare($sql);
$params = array_merge([$personId], $normalizedDates);
$stmt->execute($params);
echo json_encode([
'status' => 'success',
'message' => 'Congés supprimés pour cette personne sur les dates fournies.',
'deleted' => $stmt->rowCount(),
]);
exit;
}
// ===================== ACTION NON RECONNUE =====================
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Action non valide.']);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
@@ -1,8 +1,17 @@
<?php
// includes/api/save-events.php
header('Content-Type: application/json');
require __DIR__ . '/../includes/db.php';
require __DIR__ . '/../../../../includes/db.php';
$eventsToSave = json_decode(file_get_contents('php://input'), true);
$rawInput = file_get_contents('php://input');
$eventsToSave = json_decode($rawInput, true);
// Optionnel : tu peux commenter/supprimer ces logs en production pour économiser du disque
file_put_contents(
__DIR__ . '/events-debug.log',
"[" . date('c') . "] RAW INPUT: " . $rawInput . PHP_EOL,
FILE_APPEND
);
if (empty($eventsToSave) || !is_array($eventsToSave)) {
http_response_code(400);
@@ -13,6 +22,14 @@ if (empty($eventsToSave) || !is_array($eventsToSave)) {
$inserted = [];
try {
// 1. OPTIMISATION : On récupère toutes les personnes d'un coup (Mapping)
$stmtPeople = $pdo->query("SELECT id, name FROM pf_people");
$peopleMap = [];
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
// On crée un tableau associatif : ['Carole' => 1, 'Alex' => 2, etc.]
$peopleMap[$row['name']] = $row['id'];
}
$pdo->beginTransaction();
$sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration)
@@ -22,14 +39,9 @@ try {
foreach ($eventsToSave as $event) {
$person_id = null;
// Si un nom de personne est fourni
if (!empty($event['person'])) {
$personStmt = $pdo->prepare("SELECT id FROM pf_people WHERE name = ?");
$personStmt->execute([$event['person']]);
$personRow = $personStmt->fetch();
if ($personRow) {
$person_id = $personRow['id'];
}
// 2. On vérifie simplement dans notre tableau (plus de requête SQL ici !)
if (!empty($event['person']) && isset($peopleMap[$event['person']])) {
$person_id = $peopleMap[$event['person']];
}
$stmt->execute([
@@ -59,4 +71,4 @@ try {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $e->getMessage()]);
}
}
@@ -0,0 +1,31 @@
<?php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['person_id']) || empty($input['leave_type']) || empty($input['snapshot_date']) || !isset($input['remaining_balance'])) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']);
exit;
}
try {
// Étape 1 : Par sécurité, on supprime un éventuel snapshot existant exactement à la même date pour cette personne et ce type
$stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?");
$stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]);
// Étape 2 : On insère le nouveau solde
$stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)");
$stmt->execute([
$input['person_id'],
$input['leave_type'],
$input['snapshot_date'],
$input['remaining_balance']
]);
echo json_encode(['status' => 'success']);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
@@ -0,0 +1,68 @@
<?php
// api/save-leaves.php
header('Content-Type: application/json');
require __DIR__ . '/../../../../includes/db.php';
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input) || empty($input)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Données invalides (attendu tableau JSON).']);
exit;
}
// Exemple d'élément attendu :
// { date: '2025-09-01', person_id: 2, leave_type: 'CP', duration: 1.0 }
$validLeaveTypes = ['CP', 'JRA', 'JA'];
try {
$pdo->beginTransaction();
$stmtInsert = $pdo->prepare("
INSERT INTO pf_leaves (person_id, leave_type, leave_date, duration)
VALUES (:person_id, :leave_type, :leave_date, :duration)
");
foreach ($input as $item) {
$date = $item['date'] ?? null;
$personId = $item['person_id'] ?? null;
$leaveType = $item['leave_type'] ?? null;
$duration = isset($item['duration']) ? (float)$item['duration'] : 1.0;
if (!$date || !$personId || !$leaveType) {
continue; // on ignore les lignes incomplètes
}
if (!in_array($leaveType, $validLeaveTypes, true)) {
continue; // leave_type invalide
}
// Normalisation de la date
$dateObj = date_create($date);
if (!$dateObj) {
continue;
}
$leaveDate = $dateObj->format('Y-m-d');
$stmtInsert->execute([
':person_id' => (int)$personId,
':leave_type' => $leaveType,
':leave_date' => $leaveDate,
':duration' => $duration,
]);
}
$pdo->commit();
echo json_encode([
'status' => 'success',
'message' => 'Congés enregistrés.',
]);
} catch (PDOException $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
+373
View File
@@ -0,0 +1,373 @@
<?php
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
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__, 2) . '/includes/db.php';
$UPLOAD_DIR = '/uploads/garage/';
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_garage_documents (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maintenance_id INT DEFAULT NULL,
label VARCHAR(255) DEFAULT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) DEFAULT NULL,
mime VARCHAR(120) DEFAULT NULL,
size INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_gd_vehicle (vehicle_id),
KEY idx_gd_maint (maintenance_id),
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function gOk($d) {
$flags = JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE;
$json = json_encode(['ok' => true, 'data' => $d], $flags);
if ($json === false) { http_response_code(500); echo json_encode(['ok' => false, 'error' => 'Erreur encodage JSON : ' . json_last_error_msg()]); exit; }
echo $json; exit;
}
function gErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
function gBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
function handleUpload(string $field, string $dir): ?string {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== 0) return null;
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) return null;
$fname = uniqid('g', true) . '.' . $ext;
if (move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return $fname;
return null;
}
function handleGarageDocumentUpload(string $field, string $dir): ?array {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
return null;
}
if ($_FILES[$field]['size'] > 12 * 1024 * 1024) {
gErr('Fichier trop volumineux (max 12 Mo).', 400);
}
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
$allowed = ['pdf','jpg','jpeg','png','gif','webp','doc','docx','xls','xlsx','odt','txt'];
if (!in_array($ext, $allowed, true)) {
gErr('Format non autorisé.', 400);
}
$fname = 'd_' . uniqid('', true) . '.' . $ext;
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) {
return null;
}
$mimeMap = [
'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'odt' => 'application/vnd.oasis.opendocument.text', 'txt' => 'text/plain',
];
return [
'filename' => $fname,
'original_name' => $_FILES[$field]['name'],
'mime' => $mimeMap[$ext] ?? 'application/octet-stream',
'size' => (int) $_FILES[$field]['size'],
];
}
// ─── Vehicles ─────────────────────────────────────────────────────────────────
if ($action === 'vehicles') {
if ($method === 'GET') {
$id = $_GET['id'] ?? null;
if ($id) {
$v = $pdo->prepare("SELECT * FROM pf_vehicles WHERE id = ?"); $v->execute([$id]);
$vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
$s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
$p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL");
$dc->execute([$id]);
$vehicle['vehicle_docs_count'] = (int) $dc->fetchColumn();
$dm = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NOT NULL");
$dm->execute([$id]);
$vehicle['maint_docs_count'] = (int) $dm->fetchColumn();
gOk($vehicle);
}
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
gOk($stmt->fetchAll());
}
if ($method === 'POST') {
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
$stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['name']??'', $d['brand']??'', $d['model']??'', $d['year']??null, $d['license_plate']??null, $d['vin']??null, $d['fuel_type']??'Essence', $d['color']??null, $d['purchase_date']??null, $d['purchase_price']??null, $d['current_km']??0, $photo, $d['notes']??null]);
gOk(['id' => $pdo->lastInsertId()]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
$int_fields = ['year','current_km']; $float_fields = ['purchase_price'];
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes'];
$sets = array_map(fn($f) => "$f = ?", $fields);
$vals = array_map(function($f) use ($d, $int_fields, $float_fields) {
$v = $d[$f] ?? null;
if ($v === '' || $v === null) return null;
if (in_array($f, $int_fields)) return (int)$v;
if (in_array($f, $float_fields)) return (float)$v;
return $v;
}, $fields);
$vals[] = $id;
$pdo->prepare("UPDATE pf_vehicles SET " . implode(', ', $sets) . ", updated_at = NOW() WHERE id = ?")->execute($vals);
gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE vehicle_id = ?');
$docs->execute([$id]);
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
if ($fn) {
@unlink($UPLOAD_DIR . basename($fn));
}
}
$v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
$pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
}
}
// ─── Documents (véhicule / entretien — factures, PDF, etc.) ───────────────────
if ($action === 'garage_documents') {
if ($method === 'GET') {
$vid = $_GET['vehicle_id'] ?? null;
$mid = $_GET['maintenance_id'] ?? null;
$vehicleOnly = ($_GET['vehicle_only'] ?? '') === '1';
if ($mid) {
$stmt = $pdo->prepare("SELECT d.*, v.name as vehicle_name FROM pf_garage_documents d JOIN pf_vehicles v ON v.id = d.vehicle_id WHERE d.maintenance_id = ? ORDER BY d.created_at DESC");
$stmt->execute([$mid]);
gOk($stmt->fetchAll());
}
if (!$vid) {
gErr('vehicle_id ou maintenance_id requis');
}
if ($vehicleOnly) {
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL ORDER BY created_at DESC");
$stmt->execute([$vid]);
} else {
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? ORDER BY created_at DESC");
$stmt->execute([$vid]);
}
gOk($stmt->fetchAll());
}
if ($method === 'POST') {
$d = $_POST;
$vid = (int) ($d['vehicle_id'] ?? 0);
if ($vid < 1) {
gErr('vehicle_id manquant');
}
$mid = isset($d['maintenance_id']) && $d['maintenance_id'] !== '' ? (int) $d['maintenance_id'] : null;
if ($mid) {
$chk = $pdo->prepare("SELECT id FROM pf_maintenances WHERE id = ? AND vehicle_id = ?");
$chk->execute([$mid, $vid]);
if (!$chk->fetch()) {
gErr('Entretien invalide pour ce véhicule');
}
}
$up = handleGarageDocumentUpload('file', $UPLOAD_DIR);
if (!$up) {
gErr('Upload échoué ou fichier manquant');
}
$label = trim($d['label'] ?? '');
$pdo->prepare("INSERT INTO pf_garage_documents (vehicle_id, maintenance_id, label, filename, original_name, mime, size) VALUES (?,?,?,?,?,?,?)")
->execute([$vid, $mid, $label ?: null, $up['filename'], $up['original_name'], $up['mime'], $up['size']]);
gOk(['id' => (int) $pdo->lastInsertId()]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id < 1) {
gErr('ID manquant');
}
$r = $pdo->prepare("SELECT filename FROM pf_garage_documents WHERE id = ?");
$r->execute([$id]);
$row = $r->fetch();
if (!$row) {
gErr('Document introuvable', 404);
}
if (!empty($row['filename'])) {
@unlink($UPLOAD_DIR . $row['filename']);
}
$pdo->prepare("DELETE FROM pf_garage_documents WHERE id = ?")->execute([$id]);
gOk(['deleted' => true]);
}
}
if ($action === 'garage_document') {
$id = (int) ($_GET['id'] ?? 0);
if ($id < 1) {
gErr('ID manquant');
}
$stmt = $pdo->prepare('SELECT * FROM pf_garage_documents WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch();
if (!$doc || empty($doc['filename'])) {
http_response_code(404);
exit;
}
$f = basename($doc['filename']);
$path = $UPLOAD_DIR . $f;
if (!preg_match('/^d_[a-zA-Z0-9._-]+$/', $f) || !is_file($path)) {
http_response_code(404);
exit;
}
$mime = $doc['mime'] ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
$disp = $doc['original_name'] ?: $f;
header('Content-Disposition: inline; filename="' . rawurlencode($disp) . '"');
header('Cache-Control: private, max-age=3600');
readfile($path);
exit;
}
// ─── Maintenances ─────────────────────────────────────────────────────────────
if ($action === 'maintenances') {
$vid = $_GET['vehicle_id'] ?? null;
if ($method === 'GET') {
$id = $_GET['id'] ?? null;
if ($id) {
$stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?");
$stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404);
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE maintenance_id = ?");
$dc->execute([$id]);
$m['documents_count'] = (int) $dc->fetchColumn();
gOk($m);
}
if ($vid) {
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost, (SELECT COUNT(*) FROM pf_garage_documents d WHERE d.maintenance_id = m.id) as documents_count FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
$stmt->execute([$vid]); gOk($stmt->fetchAll());
}
$stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
gOk($stmt->fetchAll());
}
if ($method === 'POST') {
$photo = handleUpload('invoice_photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
if (!($d['vehicle_id'] ?? null)) gErr('vehicle_id manquant');
$stmt = $pdo->prepare("INSERT INTO pf_maintenances (vehicle_id,type,description,date,km,cost,mechanic,garage_name,next_km,next_date,invoice_photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['vehicle_id'], $d['type']??'', $d['description']??null, $d['date']??date('Y-m-d'), $d['km']??null, $d['cost']??0, $d['mechanic']??null, $d['garage_name']??null, $d['next_km']??null, $d['next_date']??null, $photo, $d['notes']??null]);
$mid = $pdo->lastInsertId();
if (!empty($d['km'])) { $pdo->prepare("UPDATE pf_vehicles SET current_km = GREATEST(current_km, ?), updated_at = NOW() WHERE id = ?")->execute([$d['km'], $d['vehicle_id']]); }
gOk(['id' => $mid]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
if (empty($d['type'])) gErr('Le type est requis');
if (empty($d['date'])) gErr('La date est requise');
$int_f = ['km','next_km']; $float_f = ['cost'];
$fields = ['type','description','date','km','cost','mechanic','garage_name','next_km','next_date','notes'];
$sets = array_map(fn($f) => "$f = ?", $fields);
$vals = array_map(function($f) use ($d, $int_f, $float_f) {
$v = $d[$f] ?? null;
if ($v === '' || $v === null) return null;
if (in_array($f, $int_f)) return (int)$v;
if (in_array($f, $float_f)) return (float)$v;
return $v;
}, $fields);
$vals[] = $id;
$pdo->prepare("UPDATE pf_maintenances SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE maintenance_id = ?');
$docs->execute([$id]);
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
if ($fn) {
@unlink($UPLOAD_DIR . basename($fn));
}
}
$pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
}
}
// ─── Parts ────────────────────────────────────────────────────────────────────
if ($action === 'parts') {
if ($method === 'GET') {
$vid = $_GET['vehicle_id'] ?? null; $mid = $_GET['maintenance_id'] ?? null;
$id = $_GET['id'] ?? null;
if ($id) { $stmt = $pdo->prepare("SELECT * FROM pf_parts WHERE id = ?"); $stmt->execute([$id]); $p = $stmt->fetch(); if (!$p) gErr('Pièce introuvable', 404); gOk($p); }
if ($mid) { $stmt = $pdo->prepare("SELECT * FROM pf_parts WHERE maintenance_id = ? ORDER BY created_at DESC"); $stmt->execute([$mid]); gOk($stmt->fetchAll()); }
if ($vid) { $stmt = $pdo->prepare("SELECT p.*, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id WHERE p.vehicle_id = ? ORDER BY p.created_at DESC"); $stmt->execute([$vid]); gOk($stmt->fetchAll()); }
$stmt = $pdo->query("SELECT p.*, v.name as vehicle_name, m.type as maintenance_type, m.date as maintenance_date FROM pf_parts p LEFT JOIN pf_vehicles v ON v.id = p.vehicle_id LEFT JOIN pf_maintenances m ON m.id = p.maintenance_id ORDER BY p.created_at DESC");
gOk($stmt->fetchAll());
}
if ($method === 'POST') {
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
$stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$d['vehicle_id']??null, $d['maintenance_id']??null, $d['brand']??null, $d['reference']??null, $d['name']??'', $d['category']??'Autre', $d['price']??0, $d['quantity']??1, $d['unit']??'pièce', $d['supplier']??null, $d['purchase_date']??null, $photo, $d['notes']??null]);
gOk(['id' => $pdo->lastInsertId()]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
$int_f = ['quantity']; $float_f = ['price'];
$fields = ['brand','reference','name','category','price','quantity','unit','supplier','purchase_date','notes'];
$sets = array_map(fn($f) => "$f = ?", $fields);
$vals = array_map(function($f) use ($d, $int_f, $float_f) {
$v = $d[$f] ?? null;
if ($v === '' || $v === null) return null;
if (in_array($f, $int_f)) return (int)$v;
if (in_array($f, $float_f)) return (float)$v;
return $v;
}, $fields);
$vals[] = $id;
$pdo->prepare("UPDATE pf_parts SET " . implode(', ', $sets) . " WHERE id = ?")->execute($vals); gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$r = $pdo->prepare("SELECT photo FROM pf_parts WHERE id=?"); $r->execute([$id]); $row = $r->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
$pdo->prepare("DELETE FROM pf_parts WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
}
}
// ─── Stats ────────────────────────────────────────────────────────────────────
if ($action === 'stats') {
gOk([
'vehicles' => $pdo->query("SELECT COUNT(*) FROM pf_vehicles")->fetchColumn(),
'maintenances' => $pdo->query("SELECT COUNT(*) FROM pf_maintenances")->fetchColumn(),
'parts' => $pdo->query("SELECT COUNT(*) FROM pf_parts")->fetchColumn(),
'total_cost' => $pdo->query("SELECT COALESCE(SUM(cost),0) FROM pf_maintenances")->fetchColumn(),
'total_parts_cost' => $pdo->query("SELECT COALESCE(SUM(price*quantity),0) FROM pf_parts")->fetchColumn(),
'upcoming_reminders'=> $pdo->query("SELECT COUNT(*) FROM pf_maintenances WHERE next_date >= CURDATE() OR next_km IS NOT NULL")->fetchColumn(),
]);
}
// ─── Photo upload ──────────────────────────────────────────────────────────────
if ($action === 'upload_vehicle_photo') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
$old = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $old->execute([$id]); $row = $old->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
$pdo->prepare("UPDATE pf_vehicles SET photo = ?, updated_at = NOW() WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
}
if ($action === 'photo') {
$f = basename($_GET['file'] ?? '');
if ($f && preg_match('/^[a-zA-Z0-9._-]+$/', $f) && file_exists($UPLOAD_DIR . $f)) {
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
$mime = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'][$ext] ?? 'image/jpeg';
header('Content-Type: ' . $mime);
readfile($UPLOAD_DIR . $f);
exit;
}
http_response_code(404); exit;
}
if ($action === 'upload_part_photo') {
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
$photo = handleUpload('photo', $UPLOAD_DIR); if (!$photo) gErr('Upload échoué');
$pdo->prepare("UPDATE pf_parts SET photo = ? WHERE id = ?")->execute([$photo, $id]); gOk(['photo' => $photo]);
}
gErr('Action inconnue', 404);
+525
View File
@@ -0,0 +1,525 @@
/* Garage module — light theme matching HouseHub global.css */
/* Aliases pour les inline styles JS qui utilisent var(--muted) / var(--fg) */
:root {
--muted: #64748b;
--fg: #0f172a;
}
/* ─── SOUS-NAVIGATION ──────────────────────────────────────────────────────── */
.garage-subnav {
display: flex;
gap: 4px;
padding: 10px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
}
.garage-subnav .nav-link {
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 7px 16px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
color: var(--text-muted);
transition: all .15s;
}
.garage-subnav .nav-link:hover { background: #f1f5f9; color: var(--text-main); }
.garage-subnav .nav-link.active { background: #eff6ff; color: var(--primary); border-color: #bfdbfe; }
/* ─── CONTAINER & PAGES ────────────────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.page { display: none; }
.page.active { display: block; }
/* ─── CARD ──────────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
flex-wrap: wrap;
margin-bottom: .25rem;
}
.card-title { font-size: 1rem; font-weight: 600; color: var(--text-main); }
/* ─── STATS GRID ────────────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-pill {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
border-left: 4px solid var(--border-light);
box-shadow: var(--shadow);
}
.stat-pill .label { font-size: 0.78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .35rem; }
.stat-pill .value { font-size: 1.75rem; font-weight: 700; line-height: 1; color: var(--text-main); }
.stat-pill.blue { border-left-color: var(--primary); }
.stat-pill.blue .value { color: var(--primary); }
.stat-pill.green { border-left-color: var(--success); }
.stat-pill.green .value { color: var(--success); }
.stat-pill.amber { border-left-color: var(--warning); }
.stat-pill.amber .value { color: var(--warning); }
.stat-pill.red { border-left-color: var(--danger); }
.stat-pill.red .value { color: var(--danger); }
/* ─── VEHICLES GRID ─────────────────────────────────────────────────────────── */
.vehicles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.vehicle-card {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: border-color .2s, box-shadow .2s, transform .15s;
box-shadow: var(--shadow);
}
.vehicle-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.vehicle-card-img {
width: 100%; height: 180px; overflow: hidden; background: #f1f5f9;
position: relative; display: flex; align-items: center; justify-content: center;
}
.vehicle-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.no-photo { font-size: 3.5rem; color: #cbd5e1; }
.vehicle-card-body { padding: 1rem; }
.vehicle-card-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin-bottom: .25rem; }
.vehicle-card-sub { font-size: .82rem; color: var(--text-muted); margin-bottom: .6rem; }
.vehicle-card-stats { display: flex; gap: .75rem; flex-wrap: wrap; }
.vc-stat { font-size: .82rem; color: var(--text-muted); }
.vehicle-card-footer {
padding: .6rem 1rem;
border-top: 1px solid var(--border-light);
display: flex; justify-content: space-between; align-items: center;
font-size: .8rem; color: var(--text-muted);
background: #fafafa;
}
/* ─── FUEL BADGE ────────────────────────────────────────────────────────────── */
.fuel-badge {
position: absolute; top: .5rem; right: .5rem;
font-size: .7rem; font-weight: 700;
padding: .18rem .5rem; border-radius: 6px;
text-transform: uppercase; letter-spacing: .03em;
}
.fuel-Essence { background: rgba(59,130,246,.15); color: #2563eb; }
.fuel-Diesel { background: rgba(139,92,246,.15); color: #7c3aed; }
.fuel-Hybride { background: rgba(16,185,129,.15); color: #059669; }
.fuel-Electrique { background: rgba(6,182,212,.15); color: #0891b2; }
.fuel-GPL { background: rgba(245,158,11,.15); color: #d97706; }
/* ─── PLATE ──────────────────────────────────────────────────────────────────── */
.plate {
display: inline-block; background: #1d4ed8; color: #fff;
font-family: 'Courier New', monospace; font-weight: 700;
font-size: .78rem; padding: .18rem .55rem;
border-radius: 5px; letter-spacing: .1em;
}
/* ─── TABLE ──────────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border-light); }
.table-wrap table { width: 100%; border-collapse: collapse; font-size: .875rem; }
.table-wrap table th {
background: #f8fafc; color: var(--text-muted);
font-weight: 600; text-align: left;
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap;
}
.table-wrap table td {
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light);
vertical-align: middle; color: var(--text-main);
}
.table-wrap table tr:last-child td { border-bottom: none; }
.table-wrap table tr:hover td { background: #fafbff; }
/* ─── BADGES ─────────────────────────────────────────────────────────────────── */
.badge {
display: inline-block; font-size: .72rem; font-weight: 600;
padding: .22rem .55rem; border-radius: 999px;
}
.badge-green { background: #dcfce7; color: #15803d; }
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-amber { background: #fef3c7; color: #b45309; }
.badge-red { background: #fee2e2; color: #dc2626; }
.badge-gray { background: #f1f5f9; color: var(--text-muted); }
.badge-purple { background: #ede9fe; color: #6d28d9; }
/* ─── BUTTONS ────────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: .4rem;
padding: .5rem 1rem; border-radius: 8px;
font-size: .875rem; font-weight: 500; cursor: pointer;
border: 1px solid transparent; transition: all .15s;
white-space: nowrap; text-decoration: none; line-height: 1.4;
}
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
.btn-secondary:hover { background: #f1f5f9; }
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
.btn-danger:hover { background: #fef2f2; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
.btn-icon { padding: .3rem; min-width: 30px; justify-content: center; }
/* ─── FORMS ──────────────────────────────────────────────────────────────────── */
.form-group { display: flex; flex-direction: column; gap: .35rem; }
.form-label { font-size: .82rem; font-weight: 600; color: var(--text-muted); }
.form-control {
background: #fff; border: 1px solid var(--border-light); color: var(--text-main);
border-radius: 8px; padding: .5rem .75rem; font-size: .875rem;
width: 100%; outline: none; transition: border-color .15s, box-shadow .15s;
}
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
.form-control::placeholder { color: #cbd5e1; }
select.form-control option { background: #fff; }
textarea.form-control { resize: vertical; min-height: 80px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem; }
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .9rem; }
/* ─── MODALS ─────────────────────────────────────────────────────────────────── */
.modal-backdrop {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
align-items: center; justify-content: center; padding: 1rem;
}
.modal-backdrop.show { display: flex; }
.modal {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: var(--radius);
width: 100%; max-width: 640px;
max-height: 90vh; display: flex; flex-direction: column;
box-shadow: var(--shadow-lg);
animation: modalIn .18s ease;
}
@keyframes modalIn { from { opacity: 0; transform: scale(.96) translateY(-8px); } to { opacity: 1; transform: none; } }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); flex-shrink: 0;
}
.modal-title { font-size: 1rem; font-weight: 700; color: var(--text-main); }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.25rem; line-height: 1; padding: .2rem; border-radius: 6px; }
.modal-close:hover { background: #f1f5f9; color: var(--text-main); }
.modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
.modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; flex-shrink: 0; }
/* ─── VEHICLE DETAIL HEADER ─────────────────────────────────────────────────── */
.vehicle-detail-header {
display: flex; gap: 1.5rem; align-items: flex-start;
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}
.vehicle-detail-img {
width: 180px; height: 130px; border-radius: 8px; flex-shrink: 0;
background: #f1f5f9; overflow: hidden; display: flex; align-items: center; justify-content: center;
}
.vehicle-detail-img img { width: 100%; height: 100%; object-fit: cover; }
.vehicle-meta h2 { font-size: 1.4rem; font-weight: 800; color: var(--text-main); margin-bottom: .25rem; }
.vehicle-meta .sub { font-size: .9rem; color: var(--text-muted); margin-bottom: .75rem; }
.meta-grid { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-top: .5rem; }
.meta-item .k { font-size: .72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
.meta-item .v { font-size: .95rem; font-weight: 600; color: var(--text-main); }
/* ─── TABS ───────────────────────────────────────────────────────────────────── */
.tabs { display: flex; gap: .25rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border-light); }
.tab-btn {
background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: .9rem; font-weight: 500;
padding: .6rem 1rem; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color .15s, border-color .15s;
}
.tab-btn:hover { color: var(--text-main); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* ─── PARTS ──────────────────────────────────────────────────────────────────── */
.part-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border-light); }
/* ─── EMPTY STATE ────────────────────────────────────────────────────────────── */
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: .75rem; padding: 3rem 1rem; color: var(--text-muted); text-align: center;
}
.empty-state .icon { font-size: 2.5rem; opacity: .4; }
.empty-state p { font-size: .9rem; }
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
.toast-container {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
display: flex; flex-direction: column; align-items: flex-end;
gap: .5rem; pointer-events: none;
}
.toast {
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: 10px; padding: .75rem 1rem;
font-size: .875rem; min-width: 220px; max-width: 360px; color: var(--text-main);
box-shadow: var(--shadow-lg); animation: toastIn .2s ease; pointer-events: auto;
border-left: 4px solid var(--border-light);
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.info { border-left-color: var(--primary); }
.toast.warning { border-left-color: var(--warning); }
@keyframes toastIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: none; } }
/* ─── SCROLL LIST (pièces connues) ──────────────────────────────────────────── */
.scroll-list {
max-height: 160px; overflow-y: auto;
border: 1px solid var(--border-light); border-radius: 8px;
background: #fff; margin-bottom: 1rem;
}
.scroll-list-item {
display: flex; align-items: center; gap: .75rem;
padding: .45rem .75rem; cursor: pointer;
border-bottom: 1px solid var(--border-light); transition: background .12s;
user-select: none;
}
.scroll-list-item:last-child { border-bottom: none; }
.scroll-list-item:hover { background: #f8fafc; }
.scroll-list-item input[type="radio"] { flex-shrink: 0; }
.item-name { flex: 1; font-size: .875rem; font-weight: 500; color: var(--text-main); }
.item-ref { font-size: .72rem; font-family: monospace; color: var(--text-muted); }
.item-brand { font-size: .75rem; color: var(--text-muted); }
.item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; }
/* ─── DASHBOARD LAYOUT ─────────────────────────────────────────────────────── */
.garage-dash-layout {
display: grid;
grid-template-columns: 1fr minmax(260px, 380px);
gap: 1.5rem;
align-items: start;
}
.garage-dash-main { min-width: 0; }
.garage-dash-aside { padding: 1rem 1.25rem; }
.garage-card-header { flex-wrap: wrap; align-items: center; gap: 0.75rem; }
.garage-tab-actions {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
/* ─── TABS (scroll mobile) ─────────────────────────────────────────────────── */
.garage-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: 0.15rem;
padding-bottom: 2px;
}
.garage-tabs .tab-btn {
flex-shrink: 0;
white-space: nowrap;
padding: 0.55rem 0.75rem;
font-size: 0.82rem;
}
/* ─── VEHICLE HEADER ACTIONS ───────────────────────────────────────────────── */
.vehicle-detail-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-self: flex-start;
}
.garage-docs-recap {
margin-top: 0.75rem;
padding: 0.65rem 0.85rem;
background: #f8fafc;
border: 1px solid var(--border-light);
border-radius: 8px;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.45;
}
.garage-docs-recap strong { color: var(--text-main); }
.btn-link-sm {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--primary);
font-size: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link-sm:hover { color: var(--primary-dark); }
.garage-maint-doc-badge { font-size: 0.65rem; vertical-align: middle; margin-left: 0.15rem; }
/* ─── DOCUMENTS ───────────────────────────────────────────────────────────── */
.garage-docs-card .card-header { padding: 1rem 1.25rem; }
.garage-doc-upload-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
margin-top: 0.75rem;
}
.garage-doc-upload-btn { align-self: flex-end; flex-shrink: 0; }
.garage-doc-hint {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.35rem 0 0.75rem;
}
.garage-doc-empty { padding: 1.25rem !important; }
.garage-doc-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 8px;
overflow: hidden;
}
.garage-doc-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border-light);
background: #fff;
}
.garage-doc-item:last-child { border-bottom: none; }
.garage-doc-link {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--primary);
text-decoration: none;
word-break: break-word;
}
.garage-doc-link:hover { text-decoration: underline; }
.garage-doc-meta {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.garage-doc-item .btn-icon { flex-shrink: 0; margin-left: auto; }
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.container { padding: 1rem; }
.vehicle-detail-header { flex-direction: column; }
.vehicle-detail-img { width: 100%; height: 200px; }
.vehicle-detail-actions {
width: 100%;
flex-direction: column;
align-self: stretch;
}
.vehicle-detail-actions .btn { width: 100%; justify-content: center; }
.form-row, .form-row-3 { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.modal { max-height: 95vh; }
.vehicles-grid { grid-template-columns: 1fr; }
.meta-grid { gap: 0.75rem; }
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
.garage-subnav .nav-link { white-space: nowrap; }
.garage-dash-layout { grid-template-columns: 1fr; }
.garage-dash-aside { order: -1; }
.garage-doc-upload-bar { flex-direction: column; align-items: stretch; }
.garage-doc-upload-btn { width: 100%; align-self: stretch; }
.garage-doc-item { flex-direction: column; align-items: stretch; }
.garage-doc-item .btn-icon { margin-left: 0; align-self: flex-end; }
.garage-doc-meta { white-space: normal; }
.toast-container {
left: 0.75rem;
right: 0.75rem;
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
align-items: stretch;
}
.toast { max-width: none; min-width: 0; width: 100%; }
}
@media (max-width: 640px) {
.table-wrap {
margin-left: -0.25rem;
margin-right: -0.25rem;
border-radius: 6px;
}
.table-wrap table { min-width: 640px; }
#inline-part-form .form-row,
#inline-part-form .form-row-3 { grid-template-columns: 1fr !important; }
}
/* ══ DARK MODE — Garage module ══════════════════════════════════ */
[data-theme="dark"] { --muted: #8b949e; --fg: #e6edf3; }
[data-theme="dark"] .garage-subnav { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .garage-subnav .nav-link { color: var(--text-muted); }
[data-theme="dark"] .garage-subnav .nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
[data-theme="dark"] .garage-subnav .nav-link.active { background: rgba(59,130,246,.15); color: var(--primary); border-color: rgba(59,130,246,.3); }
[data-theme="dark"] .card,
[data-theme="dark"] .vehicle-detail-header { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .stat-pill { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .stat-pill .label { color: var(--text-muted); }
[data-theme="dark"] .vehicle-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .vehicle-card:hover { border-color: var(--primary); }
[data-theme="dark"] .vehicle-card-img { background: #1c2128; }
[data-theme="dark"] .vehicle-card-footer { background: #1c2128; border-color: var(--border-light); }
[data-theme="dark"] .vehicle-card-title { color: var(--text-main); }
[data-theme="dark"] .vehicle-card-sub { color: var(--text-muted); }
[data-theme="dark"] .vehicle-detail-img { background: #1c2128; }
[data-theme="dark"] .vehicle-meta h2 { color: var(--text-main); }
[data-theme="dark"] .meta-item .v { color: var(--text-main); }
[data-theme="dark"] .card-title, [data-theme="dark"] .modal-title { color: var(--text-main); }
[data-theme="dark"] .table-wrap { border-color: var(--border-light); }
[data-theme="dark"] .table-wrap table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); }
[data-theme="dark"] .table-wrap table td { border-color: rgba(48,54,61,.4); color: var(--text-main); }
[data-theme="dark"] .table-wrap table tr:hover td { background: rgba(255,255,255,.03); }
[data-theme="dark"] .tabs { border-color: var(--border-light); }
[data-theme="dark"] .tab-btn { color: var(--text-muted); }
[data-theme="dark"] .tab-btn:hover { color: var(--text-main); }
[data-theme="dark"] .form-label { color: var(--text-muted); }
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .form-control::placeholder { color: #484f58; }
[data-theme="dark"] select.form-control option { background: #1c2128; }
[data-theme="dark"] .modal { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .modal-header,
[data-theme="dark"] .modal-footer { border-color: var(--border-light); }
[data-theme="dark"] .modal-close:hover { background: rgba(255,255,255,.06); }
[data-theme="dark"] .btn-secondary { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
[data-theme="dark"] .btn-danger { background: #1c2128; border-color: rgba(239,68,68,.4); }
[data-theme="dark"] .btn-danger:hover { background: rgba(239,68,68,.08); }
[data-theme="dark"] .badge-gray { background: rgba(139,148,158,.15); color: var(--text-muted); }
[data-theme="dark"] .toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .part-thumb { border-color: var(--border-light); }
[data-theme="dark"] .scroll-list { background: #1c2128; border-color: var(--border-light); }
[data-theme="dark"] .scroll-list-item { border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); }
[data-theme="dark"] .item-name { color: var(--text-main); }
[data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; }
[data-theme="dark"] .garage-docs-recap { background: rgba(255,255,255,.04); border-color: var(--border-light); color: var(--text-muted); }
[data-theme="dark"] .garage-docs-recap strong { color: var(--text-main); }
[data-theme="dark"] .btn-link-sm { color: var(--primary); }
[data-theme="dark"] .garage-doc-list { border-color: var(--border-light); }
[data-theme="dark"] .garage-doc-item { background: #1c2128; border-color: rgba(48,54,61,.5); }
[data-theme="dark"] .garage-doc-link { color: #93c5fd; }
[data-theme="dark"] .garage-doc-meta { color: var(--text-muted); }
+784
View File
@@ -0,0 +1,784 @@
// GarageManager - SPA Frontend
const API = '/modules/garage/api.php';
const UPLOADS = '/modules/garage/api.php?action=photo&file=';
function $(s,ctx=document){return ctx.querySelector(s);}
function $$(s,ctx=document){return [...ctx.querySelectorAll(s)];}
function fmt(n){return n!=null?Number(n).toLocaleString('fr-FR'):'--';}
function fmtPrice(n){return n!=null?Number(n).toFixed(2)+' €':'--';}
function fmtDate(d){if(!d)return '--';return new Date(d).toLocaleDateString('fr-FR');}
function formatBytes(n){
n=Number(n)||0;
if(n<1024)return n+' o';
if(n<1048576)return (n/1024).toFixed(1)+' Ko';
return (n/1048576).toFixed(1)+' Mo';
}
function ago(d){if(!d)return '';const diff=Math.floor((Date.now()-new Date(d))/86400000);if(diff===0)return "aujourd'hui";if(diff===1)return 'hier';return 'il y a '+diff+'j';}
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
function toast(msg,type='success'){
const c=document.getElementById('toasts');const t=document.createElement('div');
t.className='toast '+type;t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3000);
}
// ─── STATE ────────────────────────────────────────────────────────────────────
let currentPage='dashboard';
let currentVehicleId=null;
let currentMaintenanceId=null;
// ─── NAV ──────────────────────────────────────────────────────────────────────
function navigate(page,params={}){
currentPage=page;
$$('.page').forEach(p=>p.classList.remove('active'));
$$('.nav-link').forEach(n=>n.classList.remove('active'));
document.getElementById('page-'+page)?.classList.add('active');
const navTarget=page==='maintenance'?'maintenances-all':page==='vehicle'?'vehicles':page;
document.querySelector('.nav-link[data-page="'+navTarget+'"]')?.classList.add('active');
if(page==='dashboard')loadDashboard();
else if(page==='vehicles')loadVehicles();
else if(page==='vehicle')loadVehicleDetail(params.id);
else if(page==='maintenance')loadMaintenanceDetail(params.id);
else if(page==='parts')loadParts();
else if(page==='maintenances-all')loadAllMaintenances();
}
// ─── API ──────────────────────────────────────────────────────────────────────
async function api(action,method='GET',data=null,extra=''){
const opts={method,headers:{}};
if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
else if(data instanceof FormData){opts.body=data;}
const r=await fetch(API+'?action='+action+extra,opts);
const j=await r.json();
if(!j.ok)throw new Error(j.error||'Erreur API');
return j.data;
}
// ─── DASHBOARD ────────────────────────────────────────────────────────────────
async function loadDashboard(){
try{
const[stats,vehicles,reminders]=await Promise.all([api('stats'),api('vehicles'),api('maintenances')]);
$('#stat-vehicles').textContent=stats.vehicles;
$('#stat-maintenances').textContent=stats.maintenances;
$('#stat-parts').textContent=stats.parts;
$('#stat-cost').textContent=fmtPrice(parseFloat(stats.total_cost)+parseFloat(stats.total_parts_cost));
renderDashboardVehicles(vehicles);
renderReminders(reminders);
}catch(e){toast(e.message,'error');}
}
function renderDashboardVehicles(list){
const el=$('#dashboard-vehicles');
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🚗</div><p>Aucun véhicule</p></div>';return;}
el.innerHTML=list.slice(0,6).map(v=>vehicleCardHTML(v)).join('');
$$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
}
function vehicleCardHTML(v){
const photoHTML=v.photo?'<img src="'+UPLOADS+v.photo+'" alt="">':'<span class="no-photo">🚗</span>';
const fuel=v.fuel_type||'Essence';
const fuelClass='fuel-'+fuel.replace(/\s/g,'');
return '<div class="vehicle-card" data-id="'+v.id+'">'+
'<div class="vehicle-card-img">'+photoHTML+'<span class="fuel-badge '+fuelClass+'">'+escHtml(fuel)+'</span></div>'+
'<div class="vehicle-card-body">'+
'<div class="vehicle-card-title">'+escHtml(v.name)+'</div>'+
'<div class="vehicle-card-sub">'+escHtml(v.brand)+' '+escHtml(v.model)+(v.year?' &middot; '+v.year:'')+'</div>'+
'<div class="vehicle-card-stats">'+
'<div class="vc-stat">🔧 <strong>'+(v.maintenance_count||0)+'</strong> entretiens</div>'+
'<div class="vc-stat">💶 <strong>'+fmtPrice(v.total_cost)+'</strong></div>'+
'</div>'+
'</div>'+
'<div class="vehicle-card-footer">'+
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'<span></span>')+
'<span class="vc-stat">'+(v.current_km?fmt(v.current_km)+' km':'--')+'</span>'+
'</div></div>';
}
function renderReminders(list){
const el=$('#reminders-list');const today=new Date();
if(!list.length){el.innerHTML='<div class="empty-state" style="padding:1.5rem"><p style="color:var(--muted)">Aucun rappel configuré</p></div>';return;}
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Véhicule</th><th>Type</th><th>Prochaine date</th><th>Prochain km</th><th>Écart km</th></tr></thead><tbody>'+
list.map(r=>{
const diff=r.next_km&&r.current_km?r.next_km-r.current_km:null;
const dateOk=r.next_date?new Date(r.next_date)>today:true;
const kmOk=diff==null||diff>0;
const cls=(!dateOk||!kmOk)?'badge-red':diff!=null&&diff<2000?'badge-amber':'badge-green';
return '<tr><td><strong>'+escHtml(r.vehicle_name)+'</strong>'+(r.license_plate?' <span class="plate">'+escHtml(r.license_plate)+'</span>':'')+'</td>'+
'<td>'+escHtml(r.type)+'</td>'+
'<td>'+(r.next_date?'<span class="badge '+cls+'">'+fmtDate(r.next_date)+'</span>':'--')+'</td>'+
'<td>'+(r.next_km?fmt(r.next_km)+' km':'--')+'</td>'+
'<td>'+(diff!=null?'<span class="badge '+(diff<0?'badge-red':diff<2000?'badge-amber':'badge-green')+'">'+(diff>=0?'+':'')+fmt(diff)+' km</span>':'--')+'</td></tr>';
}).join('')+
'</tbody></table></div>';
}
// ─── VEHICLES LIST ────────────────────────────────────────────────────────────
async function loadVehicles(){
try{
const list=await api('vehicles');
const el=$('#vehicles-grid');
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🚗</div><p>Aucun véhicule. Ajoutez-en un !</p></div>';return;}
el.innerHTML=list.map(v=>vehicleCardHTML(v)).join('');
$$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
}catch(e){toast(e.message,'error');}
}
// ─── VEHICLE DETAIL ───────────────────────────────────────────────────────────
async function loadVehicleDetail(id){
currentVehicleId=id;
try{
const[v,maintenances,parts]=await Promise.all([
api('vehicles','GET',null,'&id='+id),
api('maintenances','GET',null,'&vehicle_id='+id),
api('parts','GET',null,'&vehicle_id='+id)
]);
renderVehicleHeader(v);
renderMaintenances(maintenances);
renderVehicleParts(parts);
setVehicleDocTabBadge(v);
await loadVehicleDocuments();
document.title='GarageManager · '+v.name;
}catch(e){toast(e.message,'error');}
}
function renderVehicleHeader(v){
const photoHTML=v.photo?'<img src="'+UPLOADS+v.photo+'" alt="">':'<div class="no-photo">🚗</div>';
const totalCost=(parseFloat(v.stats?.total||0)+parseFloat(v.parts_stats?.total||0)).toFixed(2);
$('#vehicle-header').innerHTML=
'<div class="vehicle-detail-header">'+
'<div class="vehicle-detail-img">'+photoHTML+'</div>'+
'<div class="vehicle-meta">'+
'<h2>'+escHtml(v.name)+'</h2>'+
'<p class="sub">'+escHtml(v.brand)+' '+escHtml(v.model)+(v.year?' · '+v.year:'')+'</p>'+
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem">'+
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'')+
'<span class="badge badge-blue">'+(v.fuel_type||'Essence')+'</span>'+
(v.color?'<span class="badge badge-gray">🎨 '+escHtml(v.color)+'</span>':'')+
'</div>'+
'<div class="meta-grid">'+
'<div class="meta-item"><span class="k">Kilométrage</span><br><span class="v">'+fmt(v.current_km)+' km</span></div>'+
'<div class="meta-item"><span class="k">Entretiens</span><br><span class="v">'+(v.stats?.cnt||0)+'</span></div>'+
'<div class="meta-item"><span class="k">Coût total</span><br><span class="v">'+fmtPrice(totalCost)+'</span></div>'+
(v.purchase_date?'<div class="meta-item"><span class="k">Achat</span><br><span class="v">'+fmtDate(v.purchase_date)+'</span></div>':'')+
(v.vin?'<div class="meta-item"><span class="k">VIN</span><br><span class="v" style="font-size:.75rem;font-family:monospace">'+escHtml(v.vin)+'</span></div>':'')+
'</div>'+
'<div class="garage-docs-recap">'+
'📎 <strong>'+(Number(v.vehicle_docs_count)||0)+'</strong> '+(Number(v.vehicle_docs_count)===1?'document':'documents')+' véhicule · '+
'<strong>'+(Number(v.maint_docs_count)||0)+'</strong> sur entretiens (factures, etc.) · '+
'<button type="button" class="btn-link-sm" onclick="switchTab(\'documents\')">Voir les documents véhicule</button>'+
'</div>'+
'</div>'+
'<div class="vehicle-detail-actions">'+
'<button class="btn btn-secondary btn-sm" onclick="openEditVehicle('+v.id+')">✏️ Modifier</button>'+
'<button class="btn btn-secondary btn-sm" onclick="openUploadPhoto('+v.id+')">📷 Photo</button>'+
'<button class="btn btn-danger btn-sm" onclick="deleteVehicle('+v.id+')">🗑️ Supprimer</button>'+
'</div>'+
'</div>';
}
function renderMaintenances(list){
const el=$('#maintenance-list');
const totalCost=list.reduce((s,m)=>s+parseFloat(m.cost||0)+parseFloat(m.parts_cost||0),0);
$('#maintenance-total').textContent=fmtPrice(totalCost);
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien enregistré</p></div>';return;}
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Kilométrage</th><th>Coût MO</th><th>Pièces</th><th>Prochain</th><th></th></tr></thead><tbody>'+
list.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
'<td><strong>'+fmtDate(m.date)+'</strong><br><span style="color:var(--muted);font-size:.75rem">'+ago(m.date)+'</span>'+
(m.documents_count>0?' <span class="badge badge-gray garage-maint-doc-badge" title="Documents / factures">📎 '+m.documents_count+'</span>':'')+
'</td>'+
'<td><span class="badge badge-blue">'+escHtml(m.type)+'</span></td>'+
'<td style="max-width:200px;color:var(--muted)">'+(m.description?escHtml(m.description):'--')+'</td>'+
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
'<td>'+fmtPrice(m.cost)+'</td>'+
'<td>'+(m.parts_count>0?'<span class="badge badge-purple">🔩 '+m.parts_count+' ('+fmtPrice(m.parts_cost)+')</span>':'--')+'</td>'+
'<td style="font-size:.78rem">'+(m.next_date?'📅 '+fmtDate(m.next_date):'')+' '+(m.next_km?'<br>🛣️ '+fmt(m.next_km)+' km':'')+'</td>'+
'<td onclick="event.stopPropagation()">'+
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditMaintenance('+m.id+')" title="Modifier">✏️</button> '+
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintenance('+m.id+')" title="Supprimer">🗑️</button>'+
'</td>'+
'</tr>').join('')+
'</tbody></table></div>';
}
function renderVehicleParts(list){
const el=$('#parts-list-vehicle');
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
$('#parts-total-vehicle').textContent=fmtPrice(total);
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔩</div><p>Aucune pièce enregistrée</p></div>';return;}
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Référence</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th>Entretien</th><th></th></tr></thead><tbody>'+
list.map(p=>'<tr>'+
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'<span style="color:var(--muted)">--</span>')+'</td>'+
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
'<td>'+fmtPrice(p.price)+'</td>'+
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
'<td style="font-size:.78rem">'+(p.maintenance_type?escHtml(p.maintenance_type)+' ('+fmtDate(p.maintenance_date)+')':'--')+'</td>'+
'<td>'+
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
'<button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">🗑️</button>'+
'</td>'+
'</tr>').join('')+
'</tbody></table></div>';
}
// ─── MAINTENANCE DETAIL ───────────────────────────────────────────────────────
async function loadMaintenanceDetail(id){
currentMaintenanceId=id;
try{
const[m,parts]=await Promise.all([
api('maintenances','GET',null,'&id='+id),
api('parts','GET',null,'&maintenance_id='+id)
]);
currentVehicleId=m.vehicle_id;
renderMaintenanceDetailHeader(m);
renderMaintenanceParts(parts,id,m.vehicle_id);
await loadMaintenanceDocuments();
loadKnownParts(m.vehicle_id);
const f=document.getElementById('inline-part-form');
if(f)f.style.display='none';
}catch(e){toast(e.message,'error');}
}
function renderMaintenanceDetailHeader(m){
document.getElementById('maintenance-detail-header').innerHTML=
'<div class="vehicle-detail-header" style="align-items:flex-start">'+
'<div class="vehicle-meta" style="flex:1">'+
'<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:.5rem">'+
'<span class="badge badge-blue" style="font-size:.95rem;padding:.35rem .8rem">'+escHtml(m.type)+'</span>'+
'<span style="color:var(--muted)">'+fmtDate(m.date)+' · '+ago(m.date)+'</span>'+
(m.documents_count>0?'<span class="badge badge-gray" title="Documents attachés à cette révision">📎 '+m.documents_count+' fichier'+(m.documents_count>1?'s':'')+'</span>':'')+
(m.vehicle_name?'<span style="color:var(--muted);font-size:.85rem">🚗 <a href="#" onclick="navigate(\'vehicle\',{id:'+m.vehicle_id+'});return false" style="color:inherit;text-decoration:underline">'+escHtml(m.vehicle_name)+'</a></span>':'')+
'</div>'+
(m.description?'<p style="margin-bottom:.75rem">'+escHtml(m.description)+'</p>':'')+
'<div class="meta-grid">'+
(m.km?'<div class="meta-item"><span class="k">Kilométrage</span><br><span class="v">'+fmt(m.km)+' km</span></div>':'')+
'<div class="meta-item"><span class="k">Coût M.O.</span><br><span class="v">'+fmtPrice(m.cost)+'</span></div>'+
(m.mechanic?'<div class="meta-item"><span class="k">Mécanicien</span><br><span class="v">'+escHtml(m.mechanic)+'</span></div>':'')+
(m.garage_name?'<div class="meta-item"><span class="k">Garage</span><br><span class="v">'+escHtml(m.garage_name)+'</span></div>':'')+
(m.next_date?'<div class="meta-item"><span class="k">Prochain entretien</span><br><span class="v">📅 '+fmtDate(m.next_date)+'</span></div>':'')+
(m.next_km?'<div class="meta-item"><span class="k">Prochain km</span><br><span class="v">🛣️ '+fmt(m.next_km)+' km</span></div>':'')+
'</div>'+
(m.notes?'<p style="margin-top:.75rem;color:var(--muted);font-size:.85rem;font-style:italic">'+escHtml(m.notes)+'</p>':'')+
'</div>'+
'<div class="vehicle-detail-actions">'+
'<button class="btn btn-secondary btn-sm" onclick="openEditMaintenance('+m.id+')">✏️ Modifier</button>'+
'<button class="btn btn-danger btn-sm" onclick="deleteMaintenance('+m.id+')">🗑️ Supprimer</button>'+
'</div>'+
'</div>';
}
function renderMaintenanceParts(list,mid,vid){
const el=document.getElementById('maintenance-parts-list');
const cnt=document.getElementById('maint-parts-count');
if(cnt)cnt.textContent=list.length?list.length+' pièce'+(list.length>1?'s':''):'';
if(!list.length){el.innerHTML='<div class="empty-state" style="padding:2rem"><div class="icon">🔩</div><p>Aucune pièce utilisée</p></div>';return;}
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Réf.</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th></th></tr></thead><tbody>'+
list.map(p=>'<tr>'+
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'--')+'</td>'+
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
'<td>'+fmtPrice(p.price)+'</td>'+
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
'<td>'+
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintPart('+p.id+','+mid+','+vid+')" title="Supprimer">🗑️</button>'+
'</td>'+
'</tr>').join('')+
'<tr style="background:rgba(0,0,0,.02)"><td colspan="7" style="text-align:right;font-weight:600;color:var(--muted)">Total pièces</td><td><strong>'+fmtPrice(total)+'</strong></td><td></td></tr>'+
'</tbody></table></div>';
}
async function deleteMaintPart(id,mid,vid){
if(!confirm('Supprimer cette pièce ?'))return;
try{
await api('parts','DELETE',null,'&id='+id);
toast('Pièce supprimée');
const parts=await api('parts','GET',null,'&maintenance_id='+mid);
renderMaintenanceParts(parts,mid,vid);
}catch(e){toast(e.message,'error');}
}
// ─── INLINE PART FORM ────────────────────────────────────────────────────────
function toggleInlinePartForm(){
const f=document.getElementById('inline-part-form');
if(!f)return;
const visible=f.style.display==='block';
f.style.display=visible?'none':'block';
if(!visible)document.getElementById('ipart-name')?.focus();
}
async function loadKnownParts(vehicleId){
try{
const parts=await api('parts','GET',null,'&vehicle_id='+vehicleId);
const el=document.getElementById('known-parts-list');
if(!el)return;
if(!parts.length){el.innerHTML='<p style="color:var(--muted);font-size:.8rem;margin-bottom:.75rem">Aucune pièce connue pour ce véhicule</p>';return;}
const seen=new Set();
const unique=parts.filter(p=>{if(seen.has(p.name))return false;seen.add(p.name);return true;});
const wrap=document.createElement('div');
wrap.className='scroll-list';
unique.forEach((p,i)=>{
const lbl=document.createElement('label');
lbl.className='scroll-list-item';
lbl.innerHTML=
'<input type="radio" name="_prefill" value="'+i+'" style="width:auto;background:none;border:none;padding:0;accent-color:var(--primary)">'+
'<span class="item-name">'+escHtml(p.name)+'</span>'+
(p.reference?'<span class="item-ref">'+escHtml(p.reference)+'</span>':'')+
(p.brand?'<span class="item-brand">'+escHtml(p.brand)+'</span>':'')+
'<span class="item-price">'+fmtPrice(p.price)+'</span>';
lbl.querySelector('input').addEventListener('change',()=>{
const n=document.getElementById('ipart-name');const b=document.getElementById('ipart-brand');
const r=document.getElementById('ipart-reference');const pr=document.getElementById('ipart-price');
if(n)n.value=p.name;
if(b)b.value=p.brand||'';
if(r)r.value=p.reference||'';
if(pr){pr.value=p.price;updateIPriceTTC();}
});
wrap.appendChild(lbl);
});
el.innerHTML='';
el.appendChild(wrap);
}catch(e){}
}
function updateIPriceTTC(){
const ht=document.getElementById('ipart-prix-ht')?.checked;
const price=parseFloat(document.getElementById('ipart-price')?.value)||0;
const preview=document.getElementById('ipart-ttc-preview');
if(!preview)return;
if(ht&&price>0){preview.textContent='→ Prix TTC : '+(price*1.2).toFixed(2)+' €';preview.style.display='block';}
else{preview.style.display='none';}
}
async function saveInlinePart(){
const name=document.getElementById('ipart-name')?.value.trim();
if(!name){toast('Nom requis','error');return;}
let price=parseFloat(document.getElementById('ipart-price')?.value)||0;
if(document.getElementById('ipart-prix-ht')?.checked)price=parseFloat((price*1.2).toFixed(2));
const fd=new FormData();
fd.append('name',name);
fd.append('vehicle_id',currentVehicleId||'');
fd.append('maintenance_id',currentMaintenanceId||'');
fd.append('price',price);
fd.append('quantity',parseInt(document.getElementById('ipart-quantity')?.value)||1);
fd.append('category','Autre');
fd.append('unit','pièce');
const brand=document.getElementById('ipart-brand')?.value.trim();
const ref=document.getElementById('ipart-reference')?.value.trim();
if(brand)fd.append('brand',brand);
if(ref)fd.append('reference',ref);
const photoFile=document.getElementById('ipart-photo')?.files[0];
if(photoFile)fd.append('photo',photoFile);
try{
await api('parts','POST',fd);
toast('Pièce ajoutée');
['ipart-name','ipart-brand','ipart-reference'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
const pr=document.getElementById('ipart-price');if(pr)pr.value='0';
const qty=document.getElementById('ipart-quantity');if(qty)qty.value='1';
const ht=document.getElementById('ipart-prix-ht');if(ht)ht.checked=false;
const prev=document.getElementById('ipart-ttc-preview');if(prev)prev.style.display='none';
const ph=document.getElementById('ipart-photo');if(ph)ph.value='';
const parts=await api('parts','GET',null,'&maintenance_id='+currentMaintenanceId);
renderMaintenanceParts(parts,currentMaintenanceId,currentVehicleId);
}catch(e){toast(e.message,'error');}
}
// ─── ALL PARTS ────────────────────────────────────────────────────────────────
async function loadParts(){
try{
const list=await api('parts');
const el=$('#all-parts-list');
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
$('#all-parts-total').textContent=fmtPrice(total);
$('#all-parts-count').textContent=list.length;
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔩</div><p>Aucune pièce</p></div>';return;}
const byVehicle={};
list.forEach(p=>{const key=p.vehicle_name||'Sans véhicule';if(!byVehicle[key])byVehicle[key]=[];byVehicle[key].push(p);});
let html='';
for(const[vname,parts] of Object.entries(byVehicle)){
const vtotal=parts.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
html+='<div style="margin-bottom:2rem">'+
'<div class="card-header" style="margin-bottom:.5rem">'+
'<h3 style="font-size:1rem;font-weight:700">🚗 '+escHtml(vname)+'</h3>'+
'<span style="margin-left:auto;color:var(--muted);font-size:.85rem">'+parts.length+' pièce'+(parts.length>1?'s':'')+' · '+fmtPrice(vtotal)+'</span>'+
'</div>'+
'<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Référence</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th>Entretien</th><th></th></tr></thead><tbody>'+
parts.map(p=>'<tr>'+
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'--')+'</td>'+
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
'<td>'+fmtPrice(p.price)+'</td>'+
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
'<td style="font-size:.78rem">'+(p.maintenance_type?escHtml(p.maintenance_type)+' ('+fmtDate(p.maintenance_date)+')':'--')+'</td>'+
'<td>'+
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
'<button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">🗑️</button>'+
'</td>'+
'</tr>').join('')+
'</tbody></table></div></div>';
}
el.innerHTML=html;
}catch(e){toast(e.message,'error');}
}
// ─── ALL MAINTENANCES ─────────────────────────────────────────────────────────
async function loadAllMaintenances(){
try{
const vehicles=await api('vehicles');
const el=$('#all-maintenances-list');
if(!vehicles.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun véhicule</p></div>';return;}
let totalAll=0,countAll=0,html='';
for(const v of vehicles){
const mlist=await api('maintenances',undefined,null,'&vehicle_id='+v.id);
if(!mlist.length)continue;
const total=mlist.reduce((s,m)=>s+parseFloat(m.cost||0)+parseFloat(m.parts_cost||0),0);
totalAll+=total;countAll+=mlist.length;
html+='<div style="margin-bottom:2rem">'+
'<div class="card-header" style="margin-bottom:.5rem">'+
'<h3 style="font-size:1rem;font-weight:700">🚗 '+escHtml(v.name)+'</h3>'+
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'')+
'<span style="margin-left:auto;color:var(--muted);font-size:.85rem">'+mlist.length+' entretiens · '+fmtPrice(total)+'</span>'+
'</div>'+
'<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Km</th><th>Coût M.O.</th><th>Pièces</th><th>Total</th><th>Mécanicien</th><th></th></tr></thead><tbody>'+
mlist.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
'<td>'+fmtDate(m.date)+'</td>'+
'<td><span class="badge badge-gray">'+escHtml(m.type)+'</span></td>'+
'<td>'+(m.description?escHtml(m.description):'--')+'</td>'+
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
'<td>'+fmtPrice(m.cost)+'</td>'+
'<td>'+(m.parts_count>0?m.parts_count+' ('+fmtPrice(m.parts_cost)+')':'--')+'</td>'+
'<td><strong>'+fmtPrice(parseFloat(m.cost||0)+parseFloat(m.parts_cost||0))+'</strong></td>'+
'<td>'+(m.mechanic?escHtml(m.mechanic):'--')+'</td>'+
'<td onclick="event.stopPropagation()">'+
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintenance('+m.id+')" title="Supprimer">🗑️</button>'+
'</td>'+
'</tr>').join('')+
'</tbody></table></div></div>';
}
$('#all-maint-count').textContent=countAll;
$('#all-maint-total').textContent=fmtPrice(totalAll);
el.innerHTML=html||'<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien</p></div>';
}catch(e){console.error(e);}
}
// ─── MODALS ───────────────────────────────────────────────────────────────────
function openModal(id){document.getElementById(id).classList.add('show');}
function closeModal(id){document.getElementById(id).classList.remove('show');}
// ─── TVA ──────────────────────────────────────────────────────────────────────
function updatePartPriceTTC(){
const ht=document.getElementById('part-prix-ht')?.checked;
const price=parseFloat(document.getElementById('part-price')?.value)||0;
const preview=document.getElementById('part-ttc-preview');
if(!preview)return;
if(ht&&price>0){preview.textContent='→ Prix TTC : '+(price*1.2).toFixed(2)+' €';preview.style.display='block';}
else{preview.style.display='none';}
}
// ─── VEHICLE CRUD ─────────────────────────────────────────────────────────────
function openAddVehicle(){
$('#form-vehicle').reset();$('#form-vehicle-id').value='';
$('#modal-vehicle-title').textContent='Ajouter un véhicule';
$('#vehicle-photo-preview').src='';$('#vehicle-photo-preview').style.display='none';
openModal('modal-vehicle');
}
function openEditVehicle(id){
api('vehicles','GET',null,'&id='+id).then(v=>{
$('#form-vehicle-id').value=v.id;
$('#modal-vehicle-title').textContent='Modifier le véhicule';
['name','brand','model','year','license_plate','vin','color','purchase_date','purchase_price','current_km','notes'].forEach(f=>{
const el=$('#vehicle-'+f.replace(/_/g,'-'));
if(el)el.value=v[f]??'';
});
setSelectVal('vehicle-fuel-type', v.fuel_type);
if(v.photo){$('#vehicle-photo-preview').src=UPLOADS+v.photo;$('#vehicle-photo-preview').style.display='block';}
else{$('#vehicle-photo-preview').style.display='none';}
openModal('modal-vehicle');
}).catch(e=>toast(e.message,'error'));
}
async function saveVehicle(){
const id=$('#form-vehicle-id').value;
const fd=new FormData($('#form-vehicle'));
try{
if(id){
const d={};for(const[k,v]of fd.entries())d[k]=v;
await api('vehicles','PUT',d,'&id='+id);
const photoFile=$('#vehicle-photo').files[0];
if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:pfd});}
toast('Véhicule modifié');
}else{
await api('vehicles','POST',fd);
toast('Véhicule ajouté');
}
closeModal('modal-vehicle');
if(currentPage==='vehicles')loadVehicles();
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
else loadDashboard();
}catch(e){toast(e.message,'error');}
}
async function deleteVehicle(id){
if(!confirm('Supprimer ce véhicule et tout son historique ?'))return;
try{await api('vehicles','DELETE',null,'&id='+id);toast('Véhicule supprimé');navigate('vehicles');}catch(e){toast(e.message,'error');}
}
// ─── MAINTENANCE CRUD ─────────────────────────────────────────────────────────
function openAddMaintenance(){
$('#form-maintenance').reset();$('#form-maintenance-id').value='';
$('#maintenance-vehicle-id').value=currentVehicleId||'';
$('#modal-maintenance-title').textContent='Ajouter un entretien';
$('#maintenance-date').value=new Date().toISOString().slice(0,10);
openModal('modal-maintenance');
}
function setSelectVal(elId, val){
const el=document.getElementById(elId); if(!el||val==null)return;
el.value=val;
if(el.value!==String(val)&&val!==''){const o=new Option(val,val);el.add(o);el.value=val;}
}
function openEditMaintenance(id){
api('maintenances','GET',null,'&id='+id).then(m=>{
$('#form-maintenance-id').value=m.id;
$('#maintenance-vehicle-id').value=m.vehicle_id;
$('#modal-maintenance-title').textContent="Modifier l'entretien";
setSelectVal('maintenance-type', m.type);
const map={description:'maintenance-description',date:'maintenance-date',
km:'maintenance-km',cost:'maintenance-cost',mechanic:'maintenance-mechanic',
garage_name:'maintenance-garage',next_date:'maintenance-next-date',next_km:'maintenance-next-km',notes:'maintenance-notes'};
for(const[k,elId] of Object.entries(map)){const el=document.getElementById(elId);if(el)el.value=m[k]??'';}
openModal('modal-maintenance');
}).catch(e=>toast(e.message,'error'));
}
async function saveMaintenance(){
const id=$('#form-maintenance-id').value;
const d={};new FormData($('#form-maintenance')).forEach((v,k)=>d[k]=v);
try{
if(id){await api('maintenances','PUT',d,'&id='+id);toast('Entretien modifié');}
else{await api('maintenances','POST',d);toast('Entretien ajouté');}
closeModal('modal-maintenance');
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
else loadVehicleDetail(currentVehicleId);
}catch(e){toast(e.message,'error');}
}
async function deleteMaintenance(id){
if(!confirm('Supprimer cet entretien ?'))return;
try{
await api('maintenances','DELETE',null,'&id='+id);
toast('Entretien supprimé');
if(currentPage==='maintenance')navigate('vehicle',{id:currentVehicleId});
else loadVehicleDetail(currentVehicleId);
}catch(e){toast(e.message,'error');}
}
// ─── PARTS CRUD ───────────────────────────────────────────────────────────────
function openAddPart(){
$('#form-part').reset();$('#form-part-id').value='';
$('#part-vehicle-id').value=currentVehicleId||'';
$('#part-maintenance-id').value=currentMaintenanceId||'';
$('#modal-part-title').textContent='Ajouter une pièce';
$('#part-photo-preview').src='';$('#part-photo-preview').style.display='none';
const ht=document.getElementById('part-prix-ht');if(ht)ht.checked=false;
const prev=document.getElementById('part-ttc-preview');if(prev)prev.style.display='none';
openModal('modal-part');
}
function openEditPart(id){
api('parts','GET',null,'&id='+id).then(p=>{
$('#form-part-id').value=p.id;
$('#part-vehicle-id').value=p.vehicle_id||'';
$('#part-maintenance-id').value=p.maintenance_id||'';
$('#modal-part-title').textContent='Modifier la pièce';
const map={name:'part-name',brand:'part-brand',reference:'part-reference',price:'part-price',quantity:'part-quantity',notes:'part-notes'};
for(const[k,elId] of Object.entries(map)){const el=document.getElementById(elId);if(el)el.value=p[k]??'';}
setSelectVal('part-category', p.category);
const ht=document.getElementById('part-prix-ht');if(ht)ht.checked=false;
const prev=document.getElementById('part-ttc-preview');if(prev)prev.style.display='none';
if(p.photo){$('#part-photo-preview').src=UPLOADS+p.photo;$('#part-photo-preview').style.display='block';}
else{$('#part-photo-preview').style.display='none';}
openModal('modal-part');
}).catch(e=>toast(e.message,'error'));
}
async function savePart(){
const id=$('#form-part-id').value;
const fd=new FormData($('#form-part'));
if(document.getElementById('part-prix-ht')?.checked){
const htPrice=parseFloat(fd.get('price'))||0;
fd.set('price',(htPrice*1.2).toFixed(2));
}
try{
if(id){
const d={};for(const[k,v]of fd.entries())d[k]=v;
await api('parts','PUT',d,'&id='+id);
const photoFile=$('#part-photo').files[0];
if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_part_photo&id='+id,{method:'POST',body:pfd});}
toast('Pièce modifiée');
}else{
await api('parts','POST',fd);
toast('Pièce ajoutée');
}
closeModal('modal-part');
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
else loadParts();
}catch(e){toast(e.message,'error');}
}
async function deletePart(id){
if(!confirm('Supprimer cette pièce ?'))return;
try{
await api('parts','DELETE',null,'&id='+id);
toast('Pièce supprimée');
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
else loadParts();
}catch(e){toast(e.message,'error');}
}
// ─── UPLOAD PHOTO ─────────────────────────────────────────────────────────────
function openUploadPhoto(id){$('#upload-vehicle-id').value=id;openModal('modal-upload-photo');}
async function doUploadPhoto(){
const id=$('#upload-vehicle-id').value;
const file=$('#upload-photo-file').files[0];
if(!file){toast('Choisissez une photo','error');return;}
const fd=new FormData();fd.append('photo',file);
try{
await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:fd});
toast('Photo mise à jour');closeModal('modal-upload-photo');
loadVehicleDetail(id);
}catch(e){toast(e.message,'error');}
}
function setVehicleDocTabBadge(v){
const el=document.getElementById('vehicle-docs-tab-count');
if(!el)return;
const n=Number(v.vehicle_docs_count)||0;
el.textContent=n>0?'('+n+')':'';
}
async function loadVehicleDocuments(){
if(!currentVehicleId)return;
try{
const list=await api('garage_documents','GET',null,'&vehicle_id='+currentVehicleId+'&vehicle_only=1');
renderGarageDocList('vehicle-documents-list',list);
}catch(e){toast(e.message,'error');}
}
async function loadMaintenanceDocuments(){
if(!currentMaintenanceId)return;
try{
const list=await api('garage_documents','GET',null,'&maintenance_id='+currentMaintenanceId);
renderGarageDocList('maintenance-documents-list',list);
}catch(e){toast(e.message,'error');}
}
function renderGarageDocList(elId,list){
const el=document.getElementById(elId);
if(!el)return;
if(!list||!list.length){
el.innerHTML='<div class="empty-state garage-doc-empty"><p>Aucun document pour linstant</p></div>';
return;
}
el.innerHTML='<ul class="garage-doc-list">'+
list.map(d=>{
const href=API+'?action=garage_document&id='+encodeURIComponent(d.id);
const title=escHtml(d.label||d.original_name||d.filename||'Document');
const meta=fmtDate(d.created_at)+' · '+formatBytes(d.size||0);
return '<li class="garage-doc-item">'+
'<a class="garage-doc-link" href="'+href+'" target="_blank" rel="noopener noreferrer">📄 '+title+'</a>'+
'<span class="garage-doc-meta">'+meta+'</span>'+
'<button type="button" class="btn btn-danger btn-sm btn-icon" onclick="deleteGarageDocument('+parseInt(d.id,10)+')" title="Supprimer">🗑️</button>'+
'</li>';
}).join('')+
'</ul>';
}
async function uploadVehicleDocument(){
const inp=document.getElementById('vehicle-doc-file');
if(!inp||!inp.files[0]){toast('Choisissez un fichier','error');return;}
const fd=new FormData();
fd.append('vehicle_id',String(currentVehicleId));
fd.append('file',inp.files[0]);
const lab=document.getElementById('vehicle-doc-label')?.value.trim();
if(lab)fd.append('label',lab);
try{
const r=await fetch(API+'?action=garage_documents',{method:'POST',body:fd});
const j=await r.json();
if(!j.ok)throw new Error(j.error||'Erreur');
toast('Document ajouté');
inp.value='';
const ll=document.getElementById('vehicle-doc-label');if(ll)ll.value='';
const v=await api('vehicles','GET',null,'&id='+currentVehicleId);
renderVehicleHeader(v);
setVehicleDocTabBadge(v);
await loadVehicleDocuments();
}catch(e){toast(e.message,'error');}
}
async function uploadMaintenanceDocument(){
const inp=document.getElementById('maint-doc-file');
if(!inp||!inp.files[0]){toast('Choisissez un fichier','error');return;}
if(!currentVehicleId||!currentMaintenanceId){toast('Contexte manquant','error');return;}
const fd=new FormData();
fd.append('vehicle_id',String(currentVehicleId));
fd.append('maintenance_id',String(currentMaintenanceId));
fd.append('file',inp.files[0]);
const lab=document.getElementById('maint-doc-label')?.value.trim();
if(lab)fd.append('label',lab);
try{
const r=await fetch(API+'?action=garage_documents',{method:'POST',body:fd});
const j=await r.json();
if(!j.ok)throw new Error(j.error||'Erreur');
toast('Document ajouté');
inp.value='';
const ll=document.getElementById('maint-doc-label');if(ll)ll.value='';
const m=await api('maintenances','GET',null,'&id='+currentMaintenanceId);
renderMaintenanceDetailHeader(m);
await loadMaintenanceDocuments();
}catch(e){toast(e.message,'error');}
}
async function deleteGarageDocument(id){
if(!confirm('Supprimer ce document ?'))return;
try{
await api('garage_documents','DELETE',null,'&id='+id);
toast('Document supprimé');
if(currentPage==='vehicle'){
const v=await api('vehicles','GET',null,'&id='+currentVehicleId);
renderVehicleHeader(v);
setVehicleDocTabBadge(v);
await loadVehicleDocuments();
}else if(currentPage==='maintenance'){
const m=await api('maintenances','GET',null,'&id='+currentMaintenanceId);
renderMaintenanceDetailHeader(m);
await loadMaintenanceDocuments();
}
}catch(e){toast(e.message,'error');}
}
// ─── TABS ────────────────────────────────────────────────────────────────────
function switchTab(tab){
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
$$('.tab-pane').forEach(p=>p.classList.remove('active'));
document.querySelector('.tab-btn[data-tab="'+tab+'"]')?.classList.add('active');
document.getElementById('tab-'+tab)?.classList.add('active');
if(tab==='documents'&&currentVehicleId)loadVehicleDocuments();
}
// ─── PHOTO PREVIEW ────────────────────────────────────────────────────────────
function previewPhoto(inputId,previewId){
const file=document.getElementById(inputId).files[0];if(!file)return;
const reader=new FileReader();
reader.onload=e=>{const img=document.getElementById(previewId);img.src=e.target.result;img.style.display='block';};
reader.readAsDataURL(file);
}
// ─── INIT ────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded',()=>{
$$('.nav-link').forEach(n=>n.addEventListener('click',()=>navigate(n.dataset.page)));
$$('.modal-backdrop').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
navigate('dashboard');
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+377
View File
@@ -0,0 +1,377 @@
/* modules/gift-list/gift-list.css */
.pf-gift-list h1 {
font-size: 1.6rem;
font-weight: 800;
margin: 0;
color: var(--text-main);
}
.cl-titlebar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 15px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-light);
padding-bottom: 15px;
}
.cl-view-switch {
display: flex;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
}
.cl-view-btn {
padding: 6px 16px;
border-radius: 6px;
text-decoration: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
transition: 0.2s;
}
.cl-view-btn.is-active {
background: var(--text-main);
color: var(--bg-panel);
}
/* === FILTRES STICKY === */
.pf-filter-bar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 24px;
position: sticky;
top: 64px;
z-index: 50;
flex-wrap: wrap;
}
.pf-filter-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
}
.pf-filter-select {
padding: 6px 12px;
border-radius: 20px; /* Forme de pillule */
border: 1px solid var(--border-light);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
outline: none;
font-family: inherit;
cursor: pointer;
box-shadow: var(--shadow-sm);
width: auto; /* Empêche de prendre toute la largeur */
appearance: none; /* Nettoie la flèche système... */
-webkit-appearance: none;
-moz-appearance: none;
/* ...pour mettre une flèche personnalisée plus discrète */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
padding-right: 28px; /* Place pour la flèche */
}
.pf-filter-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* === SECTIONS ENFANTS === */
.pf-child-section {
margin-bottom: 30px;
background: var(--bg-panel);
padding: 20px;
border-radius: 16px;
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
}
.pf-child-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.pf-child-header h3 {
margin: 0;
font-size: 1.3rem;
color: var(--text-main);
}
/* === BARRE DES TOTAUX (PILLS) === */
.pf-child-totals-bar {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 12px;
margin-bottom: 15px;
border-bottom: 1px solid var(--border-light);
scrollbar-width: none; /* Firefox */
}
.pf-child-totals-bar::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.pf-summary-pill {
white-space: nowrap;
background: var(--bg-page);
padding: 4px 12px;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-muted);
border: 1px solid var(--border-strong);
transition: opacity 0.2s;
}
/* === FEED DES CARTES CADEAUX === */
.pf-gift-feed {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.pf-gift-card-compact {
background: var(--bg-panel);
border-radius: 10px;
border: 1px solid var(--border-light);
padding: 12px;
position: relative;
transition: 0.2s;
display: flex;
flex-direction: column;
min-height: 100px;
}
.pf-gift-card-compact:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.pf-gift-title {
font-weight: 600;
color: var(--text-main);
font-size: 0.95rem;
margin: 0 0 10px 0;
line-height: 1.3;
word-break: break-word;
}
.pf-gift-link {
text-decoration: none;
margin-left: 4px;
}
.pf-gift-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.pf-gift-price {
font-weight: 800;
color: var(--success);
font-size: 1rem;
}
.pf-gift-badges-col {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.pf-pill-adult {
font-size: 0.7rem;
font-weight: 700;
background: #e0f2fe;
color: #0369a1;
padding: 2px 6px;
border-radius: 4px;
}
.pf-pill-occ {
font-size: 0.65rem;
font-weight: 700;
background: #fef3c7;
color: #b45309;
padding: 2px 6px;
border-radius: 4px;
}
.pf-gift-payer {
font-size: 0.7rem;
color: #ef4444;
font-weight: 600;
font-style: italic;
margin-top: 5px;
}
.pf-gift-actions {
display: flex;
gap: 4px;
}
/* === TRICOUNT / LIQUIDATIONS === */
.pf-tricount-list {
list-style: none;
padding: 0;
margin: 0;
}
.pf-tricount-item {
background: var(--bg-panel);
padding: 12px 15px;
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.95rem;
}
.cl-debt-matrix th:first-child,
.cl-debt-matrix td:first-child {
position: sticky;
left: 0;
background: var(--bg-page);
z-index: 2;
border-right: 2px solid var(--border-light);
}
/* Icônes des fêtes (Tió, Noël, etc.) plus petites et bien alignées */
.cl-occasion-icon {
width: 20px; /* Réduit pour être discret */
height: 20px;
object-fit: contain;
vertical-align: middle;
}
.cl-occasion-title {
display: flex;
align-items: center;
gap: 8px; /* Espace réduit entre l'icône et le texte */
}
@media (max-width: 768px) {
.pf-child-section {
padding: 15px;
}
.pf-filter-bar {
padding: 0 5px;
}
.pf-filter-label {
display: none;
}
.pf-gift-feed {
grid-template-columns: 1fr 1fr;
}
.pf-gift-title {
font-size: 0.85rem;
}
.pf-gift-price {
font-size: 0.9rem;
}
}
@media (max-width: 400px) {
.pf-gift-feed {
grid-template-columns: 1fr;
}
}
/* === COMPOSANT MULTI-SELECT VANILLA === */
.pf-multi-select {
position: relative;
display: inline-block;
}
/* Le bouton déclencheur (réutilise le style des filtres) */
.pf-ms-trigger {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid var(--border-light);
backdrop-filter: blur(8px);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
cursor: pointer;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.pf-ms-trigger:hover {
border-color: #cbd5e1;
}
.pf-ms-trigger.active {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Le menu déroulant */
.pf-ms-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 8px;
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 200px;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.pf-ms-dropdown.open {
display: flex;
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Les options (checkboxes) */
.pf-ms-option {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-main);
transition: 0.2s;
}
.pf-ms-option:hover {
background: var(--bg-page);
}
.pf-ms-option input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--primary);
width: 16px;
height: 16px;
}
.pf-ms-option.is-all {
font-weight: 700;
border-bottom: 1px solid var(--border-light);
padding-bottom: 10px;
margin-bottom: 4px;
border-radius: 6px 6px 0 0;
}
@keyframes popDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
// modules/gift-list/save-gift.php
require __DIR__ . '/../../includes/auth.php';
require_login('/login.php');
require __DIR__ . '/../../includes/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /gift-list.php');
exit;
}
// Configuration
$tableName = 'pf_gifts'; // Harmonisé avec gift-list.php
// Récupération des données
$action = $_POST['action'] ?? 'create';
$gift_id = (int)($_POST['gift_id'] ?? 0);
// Logique de redirection (pour rester sur la bonne vue)
// Par défaut on renvoie vers le referer ou vers la page principale
$redirectUrl = '/gift-list.php';
$occasionForView = $_POST['occasion'] ?? '';
if (in_array($occasionForView, ['ANNIV', 'SANT'])) {
$redirectUrl .= '?view=anniversary';
} else {
$redirectUrl .= '?view=nadal';
}
try {
// --- SUPPRESSION ---
if ($action === 'delete') {
if ($gift_id > 0) {
$stmt = $pdo->prepare("DELETE FROM {$tableName} WHERE id = :id");
$stmt->execute(['id' => $gift_id]);
}
header("Location: $redirectUrl");
exit;
}
// Champs communs
$year = (int)($_POST['year'] ?? date('Y'));
$adult_name = trim($_POST['adult_name'] ?? '');
$payer_name = trim($_POST['payer_name'] ?? '');
$child_name = trim($_POST['child_name'] ?? '');
$occasion = trim($_POST['occasion'] ?? '');
$gift_desc = trim($_POST['gift_description'] ?? '');
$prod_link = trim($_POST['product_link'] ?? '');
$amount = ($_POST['amount'] !== '') ? (float)$_POST['amount'] : 0.0;
// Si payeur vide, c'est l'adulte responsable qui paye
if ($payer_name === '') {
$payer_name = $adult_name;
}
// Validation minimale
if (!$adult_name || !$child_name || !$occasion || !$gift_desc) {
// En cas d'erreur, on redirige sans rien faire (ou on pourrait gérer une erreur)
header("Location: $redirectUrl");
exit;
}
// --- UPDATE ---
if ($action === 'update' && $gift_id > 0) {
$stmt = $pdo->prepare("
UPDATE {$tableName}
SET year = :year,
adult_name = :adult_name,
payer_name = :payer_name,
child_name = :child_name,
occasion = :occasion,
gift_description = :gift_description,
product_link = :product_link,
amount = :amount
WHERE id = :id
");
$stmt->execute([
'id' => $gift_id,
'year' => $year,
'adult_name' => $adult_name,
'payer_name' => $payer_name,
'child_name' => $child_name,
'occasion' => $occasion,
'gift_description' => $gift_desc,
'product_link' => $prod_link ?: null,
'amount' => $amount,
]);
}
// --- CREATE ---
else {
$stmt = $pdo->prepare("
INSERT INTO {$tableName}
(year, adult_name, payer_name, child_name, occasion, gift_description, product_link, amount)
VALUES
(:year, :adult_name, :payer_name, :child_name, :occasion, :gift_description, :product_link, :amount)
");
$stmt->execute([
'year' => $year,
'adult_name' => $adult_name,
'payer_name' => $payer_name,
'child_name' => $child_name,
'occasion' => $occasion,
'gift_description' => $gift_desc,
'product_link' => $prod_link ?: null,
'amount' => $amount,
]);
}
} catch (PDOException $e) {
// Log l'erreur si besoin
}
header("Location: $redirectUrl");
exit;
+260
View File
@@ -0,0 +1,260 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
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__, 2) . '/includes/db.php';
require_once dirname(__DIR__, 2) . '/includes/i18n.php';
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
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
) 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
");
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function gOk($d)
{
echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE);
exit;
}
function gErr($m, $c = 400)
{
http_response_code($c);
echo json_encode(['ok' => false, 'error' => $m]);
exit;
}
function gBody()
{
return json_decode(file_get_contents('php://input'), true) ?? [];
}
function groceryNormalize(string $label): string
{
$t = trim($label);
if ($t === '') {
return '';
}
if (function_exists('mb_strtolower')) {
$t = mb_strtolower($t, 'UTF-8');
} else {
$t = strtolower($t);
}
if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) {
$t = mb_substr($t, 0, 500, 'UTF-8');
} elseif (strlen($t) > 500) {
$t = substr($t, 0, 500);
}
return $t;
}
function groceryHistoryMax(PDO $pdo): int
{
try {
$s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'");
$v = $s ? $s->fetchColumn() : false;
$n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20;
} catch (\Throwable $e) {
$n = 20;
}
return max(1, min(50, $n));
}
function groceryTouchHistory(PDO $pdo, string $labelDisplay): void
{
$norm = groceryNormalize($labelDisplay);
if ($norm === '') {
return;
}
$disp = trim($labelDisplay);
if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) {
$disp = mb_substr($disp, 0, 500, 'UTF-8');
} elseif (strlen($disp) > 500) {
$disp = substr($disp, 0, 500);
}
$hash = hash('sha256', $norm, false);
$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, $disp]);
$max = groceryHistoryMax($pdo);
$cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn();
if ($cnt <= $max) {
return;
}
$toDel = $cnt - $max;
$stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?');
$stmt->execute([$toDel]);
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$ids) {
return;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids);
}
function groceryCurrentNormSet(PDO $pdo): array
{
$rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
$set = [];
foreach ($rows as $lab) {
$n = groceryNormalize((string) $lab);
if ($n !== '') {
$set[$n] = true;
}
}
return $set;
}
if ($action === 'items') {
if ($method === 'GET') {
$rows = $pdo->query(
"SELECT id, label, in_cart, position, created_at, updated_at
FROM pf_grocery_items
ORDER BY in_cart ASC, position ASC, id ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['in_cart'] = (int) $r['in_cart'];
$r['id'] = (int) $r['id'];
$r['position'] = (int) $r['position'];
}
unset($r);
gOk($rows);
}
if ($method === 'POST') {
$d = gBody();
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$norm = groceryNormalize($label);
$chk = $pdo->query('SELECT label FROM pf_grocery_items');
while ($existing = $chk->fetchColumn()) {
if (groceryNormalize((string) $existing) === $norm) {
gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.');
}
}
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
$id = (int) $pdo->lastInsertId();
groceryTouchHistory($pdo, $label);
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
$row['in_cart'] = (int) $row['in_cart'];
$row['id'] = (int) $row['id'];
$row['position'] = (int) $row['position'];
gOk($row);
}
if ($method === 'PUT') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$d = gBody();
if (array_key_exists('in_cart', $d)) {
$in = !empty($d['in_cart']) ? 1 : 0;
$pdo->prepare('UPDATE pf_grocery_items SET in_cart=?, updated_at=NOW() WHERE id=?')->execute([$in, $id]);
gOk(['in_cart' => $in]);
}
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
groceryTouchHistory($pdo, $label);
gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$pdo->prepare('DELETE FROM pf_grocery_items WHERE id=?')->execute([$id]);
gOk(['deleted' => true]);
}
}
if ($action === 'history' && $method === 'GET') {
$max = groceryHistoryMax($pdo);
$rows = $pdo->query(
'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100'
)->fetchAll(PDO::FETCH_COLUMN);
$onList = groceryCurrentNormSet($pdo);
$labels = [];
foreach ($rows as $disp) {
$disp = (string) $disp;
if (groceryNormalize($disp) === '') {
continue;
}
if (isset($onList[groceryNormalize($disp)])) {
continue;
}
$labels[] = $disp;
if (count($labels) >= $max) {
break;
}
}
gOk(['labels' => $labels, 'history_max' => $max]);
}
if ($action === 'uncheck_all' && $method === 'POST') {
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
gOk(['updated' => true]);
}
if ($action === 'delete_picked' && $method === 'POST') {
$labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN);
foreach ($labs as $lab) {
groceryTouchHistory($pdo, (string) $lab);
}
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
gOk(['deleted' => true]);
}
if ($action === 'clear_all' && $method === 'POST') {
$labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
foreach ($labs as $lab) {
groceryTouchHistory($pdo, (string) $lab);
}
$pdo->exec('DELETE FROM pf_grocery_items');
gOk(['deleted' => true]);
}
gErr('Action inconnue', 404);
+348
View File
@@ -0,0 +1,348 @@
/* HouseHub — Courses (liste de courses) */
.groceries-layout {
max-width: 600px;
margin: 0 auto;
padding: 0 1rem 2.5rem;
}
.groceries-hero {
padding: 1.25rem 0 1rem;
}
.groceries-hero h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
color: var(--text-main);
}
.groceries-hero p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
}
/* Encart ajout — plus visible, aligné sur les cartes HouseHub */
.groceries-add-card {
margin-bottom: 1.35rem;
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-panel);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.groceries-add-card-inner {
padding: 1.15rem 1.25rem 1.25rem;
}
.groceries-quick-add {
display: flex;
align-items: stretch;
gap: 0.75rem;
}
.groceries-quick-add .groceries-add-input {
flex: 1;
min-width: 0;
padding: 14px 16px;
font-size: 1.05rem;
border-radius: 10px;
line-height: 1.35;
}
.groceries-add-submit {
flex-shrink: 0;
padding: 14px 22px !important;
font-size: 1rem !important;
border-radius: 10px !important;
white-space: nowrap;
}
@media (max-width: 520px) {
.groceries-quick-add {
flex-direction: column;
}
.groceries-add-submit {
width: 100%;
justify-content: center;
}
}
/* Actions secondaires — même famille que pf-btn du reste du site */
.groceries-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1.15rem;
}
.groceries-toolbar-btn {
padding: 10px 18px !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
border-radius: 10px !important;
}
.groceries-toolbar-btn--danger {
color: var(--danger) !important;
border-color: rgba(239, 68, 68, 0.35) !important;
background: var(--bg-panel) !important;
}
.groceries-toolbar-btn--danger:hover {
background: rgba(239, 68, 68, 0.08) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
color: var(--danger) !important;
}
.groceries-toolbar-btn--outline {
border: 1px dashed var(--border-strong) !important;
background: transparent !important;
color: var(--text-muted) !important;
}
.groceries-toolbar-btn--outline:hover {
background: var(--bg-page) !important;
color: var(--text-main) !important;
border-color: var(--text-muted) !important;
}
.groceries-section-title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 1rem 0 0.5rem;
}
.groceries-section-title:first-of-type {
margin-top: 0;
}
.groceries-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.groceries-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 12px;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.groceries-row:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.groceries-row.in-cart {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--bg-panel) 48%);
border-color: rgba(34, 197, 94, 0.28);
}
.groceries-row.in-cart:hover {
border-color: rgba(34, 197, 94, 0.45);
}
.groceries-row.in-cart .groceries-label {
text-decoration: line-through;
color: var(--text-muted);
}
.groceries-check {
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
border: 2px solid var(--border-light);
background: var(--bg-page);
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
color: transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s, transform 0.12s;
}
.groceries-check:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.08);
}
.groceries-check:active {
transform: scale(0.96);
}
/* Produit validé / dans le caddie — vert franc */
.groceries-check.checked {
background: linear-gradient(145deg, #22c55e 0%, #16a34a 100%);
border-color: #15803d;
color: #fff;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 2px 6px rgba(22, 163, 74, 0.35);
}
.groceries-check.checked:hover {
background: linear-gradient(145deg, #4ade80 0%, #22c55e 100%);
border-color: #166534;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35), 0 3px 10px rgba(22, 163, 74, 0.4);
}
.groceries-check.checked::after {
content: '✓';
}
.groceries-label {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
line-height: 1.35;
word-break: break-word;
}
.groceries-row-actions {
display: flex;
gap: 0.15rem;
flex-shrink: 0;
align-items: center;
}
.groceries-empty {
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.groceries-empty .groceries-empty-icon {
font-size: 2.75rem;
opacity: 0.35;
margin-bottom: 0.75rem;
}
.groceries-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groceries-toast {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 0.7rem 1rem;
font-size: 0.875rem;
min-width: 200px;
color: var(--text-main);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border-left: 4px solid var(--primary);
}
.groceries-toast.error {
border-left-color: var(--danger);
}
[data-theme='dark'] .groceries-add-card {
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
}
[data-theme='dark'] .groceries-row {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme='dark'] .groceries-row:hover {
border-color: #484f58;
}
[data-theme='dark'] .groceries-row.in-cart {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--bg-panel) 50%);
border-color: rgba(34, 197, 94, 0.35);
}
[data-theme='dark'] .groceries-check {
border-color: #484f58;
background: var(--bg-page);
}
[data-theme='dark'] .groceries-check:hover {
background: rgba(59, 130, 246, 0.12);
border-color: var(--primary);
}
[data-theme='dark'] .groceries-check.checked {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
}
/* Historique produits */
.groceries-history-card {
margin-top: 1.75rem;
padding: 1.15rem 1.2rem 1.2rem;
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-panel);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05);
}
.groceries-history-h2 {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.35rem;
color: var(--text-main);
}
.groceries-history-meta {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0 0 0.85rem;
line-height: 1.45;
}
.groceries-history-settings-link {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.groceries-history-settings-link:hover {
text-decoration: underline;
}
.groceries-history-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.groceries-history-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.75rem 0.45rem 0.55rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
color: var(--text-main);
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
text-align: left;
max-width: 100%;
}
.groceries-history-chip:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.06);
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12);
}
.groceries-history-chip-plus {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--primary);
color: var(--text-on-primary, #fff);
font-size: 0.85rem;
font-weight: 700;
flex-shrink: 0;
}
.groceries-history-empty {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.4;
}
[data-theme='dark'] .groceries-history-card {
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
}
[data-theme='dark'] .groceries-history-chip {
background: var(--bg-page);
border-color: var(--border-light);
}
[data-theme='dark'] .groceries-history-chip:hover {
background: rgba(59, 130, 246, 0.1);
}
+330
View File
@@ -0,0 +1,330 @@
// HouseHub — Liste de courses
const API = '/modules/groceries/api.php';
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
function T(key, fallback) {
if (typeof tr === 'function') {
const v = tr(key);
if (v && v !== key) return v;
}
return fallback;
}
function escAttr(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function toast(msg, type = 'success') {
const c = document.getElementById('groceries-toasts');
const t = document.createElement('div');
t.className = 'groceries-toast' + (type === 'error' ? ' error' : '');
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function api(action, method = 'GET', data = null, extra = '') {
const opts = {
method,
credentials: 'same-origin',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
};
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const r = await fetch(API + '?action=' + action + extra, opts);
const text = await r.text();
let j;
try {
j = JSON.parse(text);
} catch (e) {
console.error('Bad JSON from API:', text.slice(0, 200));
throw new Error(T('error_occured', 'Erreur serveur'));
}
if (!j.ok) throw new Error(j.error || T('error_occured', 'Erreur'));
return j.data;
}
async function loadAll() {
try {
const [items, hist] = await Promise.all([api('items', 'GET'), api('history', 'GET')]);
render(items);
updateSubtitle(items);
renderHistory(hist);
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
function updateSubtitle(items) {
const el = document.getElementById('groceries-subtitle');
if (!el) return;
const pending = items.filter((i) => !parseInt(i.in_cart, 10)).length;
const cart = items.filter((i) => parseInt(i.in_cart, 10)).length;
if (!items.length) {
el.textContent = '';
return;
}
const tpl = typeof tr === 'function' ? tr('groceries_subtitle') : '';
el.textContent = (tpl && tpl !== 'groceries_subtitle' ? tpl : '{pending} à prendre · {cart} dans le caddie')
.replace('{pending}', String(pending))
.replace('{cart}', String(cart));
}
function renderHistory(hist) {
const root = document.getElementById('groceries-history-root');
if (!root) return;
const max = hist && typeof hist.history_max === 'number' ? hist.history_max : 20;
const labels = (hist && hist.labels) || [];
const title = escHtml(T('groceries_history_title', 'Historique'));
const metaTpl = T('groceries_history_meta', 'Jusqu’à {max} produits · ');
const meta = escHtml(metaTpl.replace('{max}', String(max)));
const linkLabel = escHtml(T('groceries_settings_link', 'Paramètres'));
let chips = '';
labels.forEach((lab) => {
const l = String(lab);
chips +=
'<button type="button" class="groceries-history-chip" data-label="' +
escAttr(l) +
'"><span class="groceries-history-chip-plus">+</span>' +
escHtml(l) +
'</button>';
});
const emptyHint = labels.length
? ''
: '<p class="groceries-history-empty">' + escHtml(T('groceries_history_empty', '')) + '</p>';
root.innerHTML =
'<div class="groceries-history-inner">' +
'<div class="groceries-history-head">' +
'<h2 class="groceries-history-h2">' +
title +
'</h2>' +
'<p class="groceries-history-meta">' +
meta +
'<a href="/settings.php" class="groceries-history-settings-link">' +
linkLabel +
'</a></p></div>' +
'<div class="groceries-history-chips">' +
chips +
'</div>' +
emptyHint +
'</div>';
}
function render(items) {
const wrap = document.getElementById('groceries-lists');
if (!items.length) {
const msg = T('groceries_empty', 'Rien pour le moment. Ajoutez un produit ci-dessus.');
wrap.innerHTML =
'<div class="groceries-empty"><div class="groceries-empty-icon">🛒</div><p>' + escHtml(msg) + '</p></div>';
return;
}
const pending = items.filter((i) => !parseInt(i.in_cart, 10));
const cart = items.filter((i) => parseInt(i.in_cart, 10));
const labPending = escHtml(T('groceries_section_pending', 'À prendre'));
const labCart = escHtml(T('groceries_section_cart', 'Dans le caddie'));
let html = '';
if (pending.length) {
html += '<div class="groceries-section-title">' + labPending + '</div><div class="groceries-list">';
pending.forEach((i) => {
html += rowHtml(i, false);
});
html += '</div>';
}
if (cart.length) {
html += '<div class="groceries-section-title">' + labCart + '</div><div class="groceries-list">';
cart.forEach((i) => {
html += rowHtml(i, true);
});
html += '</div>';
}
wrap.innerHTML = html;
}
function rowHtml(i, inCart) {
const id = i.id;
const checkClass = 'groceries-check' + (inCart ? ' checked' : '');
const rowClass = 'groceries-row' + (inCart ? ' in-cart' : '');
const aria = escAttr(T('groceries_aria_toggle', 'Marquer dans le caddie'));
return (
'<div class="' +
rowClass +
'" data-id="' +
id +
'">' +
'<button type="button" class="' +
checkClass +
'" onclick="toggleCart(' +
id +
',' +
(inCart ? '0' : '1') +
')" aria-label="' +
aria +
'"></button>' +
'<div class="groceries-label">' +
escHtml(i.label) +
'</div>' +
'<div class="groceries-row-actions">' +
'<button type="button" class="btn-icon-action edit" onclick="editItem(' +
id +
')" title="' +
escAttr(T('edit', 'Modifier')) +
'" aria-label="' +
escAttr(T('edit', 'Modifier')) +
'">✏️</button>' +
'<button type="button" class="btn-icon-action delete" onclick="deleteItem(' +
id +
')" title="' +
escAttr(T('delete', 'Supprimer')) +
'" aria-label="' +
escAttr(T('delete', 'Supprimer')) +
'">🗑️</button>' +
'</div></div>'
);
}
async function addFromInput() {
const input = document.getElementById('groceries-new');
const label = (input.value || '').trim();
if (!label) return;
try {
await api('items', 'POST', { label: label });
input.value = '';
input.focus();
toast(T('groceries_added', 'Ajouté'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function addFromHistory(label) {
const t = String(label || '').trim();
if (!t) return;
try {
await api('items', 'POST', { label: t });
toast(T('groceries_added', 'Ajouté'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function toggleCart(id, next) {
try {
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function editItem(id) {
const row = document.querySelector('.groceries-row[data-id="' + id + '"]');
const current = row ? row.querySelector('.groceries-label').textContent : '';
const label = window.prompt(T('groceries_prompt_edit', 'Modifier le produit'), current);
if (label === null) return;
const t = label.trim();
if (!t) {
toast(T('groceries_label_empty', 'Libellé vide'), 'error');
return;
}
try {
await api('items', 'PUT', { label: t }, '&id=' + id);
toast(T('groceries_updated', 'Mis à jour'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deleteItem(id) {
const ok = await pachaConfirm(
T('groceries_confirm_del_title', 'Supprimer ?'),
T('groceries_confirm_del_body', 'Retirer ce produit de la liste ?')
);
if (!ok) return;
try {
await api('items', 'DELETE', null, '&id=' + id);
toast(T('groceries_deleted', 'Supprimé'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function uncheckAll() {
const ok = await pachaConfirm(
T('groceries_confirm_uncheck_title', 'Tout remettre à prendre'),
T('groceries_confirm_uncheck_body', '')
);
if (!ok) return;
try {
await api('uncheck_all', 'POST', {});
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deletePicked() {
const ok = await pachaConfirm(
T('groceries_confirm_clear_title', 'Retirer les produits du caddie'),
T('groceries_confirm_clear_body', '')
);
if (!ok) return;
try {
await api('delete_picked', 'POST', {});
toast(T('groceries_picked_removed', 'Articles retirés'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function clearWholeList() {
const ok = await pachaConfirm(
T('groceries_confirm_empty_list_title', 'Vider toute la liste ?'),
T('groceries_confirm_empty_list_body', '')
);
if (!ok) return;
try {
await api('clear_all', 'POST', {});
toast(T('groceries_list_cleared', 'Liste vidée'));
loadAll();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('groceries-new');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addFromInput();
}
});
}
const histRoot = document.getElementById('groceries-history-root');
if (histRoot) {
histRoot.addEventListener('click', (e) => {
const btn = e.target.closest('.groceries-history-chip');
if (!btn || !histRoot.contains(btn)) return;
const raw = btn.getAttribute('data-label');
if (raw) addFromHistory(raw);
});
}
loadAll();
});
File diff suppressed because it is too large Load Diff
+999
View File
@@ -0,0 +1,999 @@
// ============================================================================
// FONCTION DE TRADUCTION JS & LANGUE COURANTE
// ============================================================================
function tr(key) {
return window.I18N && window.I18N[key] ? window.I18N[key] : key;
}
// On utilise 'var' au lieu de 'const/let' pour éviter les crashs si le fichier est lu 2 fois
var currentLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
var selectedItemIdForMove = null; // Déplacé ici pour plus de clarté
// ============================================================================
// UTILITAIRES MÉTÉO
// ============================================================================
function getWeatherInfo(code) {
// Transformation en conditions pour regrouper les codes WMO
if (code === 0) return { icon: "☀️", label: tr("weather_sunny") };
if ([1, 2].includes(code)) return { icon: "🌤️", label: tr("weather_sunny") };
if ([3, 45, 48].includes(code))
return { icon: "☁️", label: tr("weather_cloudy") };
// Les codes 51 à 67 et 80 à 82 couvrent toutes les formes de pluie et bruine
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code))
return { icon: "🌧️", label: tr("weather_rainy") };
// Les codes neigeux
if ([71, 73, 75, 77, 85, 86].includes(code))
return { icon: "❄️", label: tr("weather_snowy") };
// Orages
if ([95, 96, 99].includes(code))
return { icon: "⛈️", label: tr("weather_rainy") };
return { icon: "🌡️", label: tr("weather_forecast") };
}
async function loadWeatherForStep(pt) {
if (!pt.step_start_date || !pt.lat || !pt.lng) return;
const container = document.querySelector(
`#step-card-${pt.sort_order} .hol-weather-info`,
);
if (!container) return;
try {
const resp = await fetch(
`/modules/holidays/includes/api/get_weather.php?lat=${pt.lat}&lng=${pt.lng}&date=${pt.step_start_date}`,
);
const res = await resp.json();
console.log(`Météo pour ${pt.location_name} :`, res);
if (res.success) {
const info = getWeatherInfo(res.data.code);
// Si c'est une estimation basée sur le passé, on adapte l'affichage
const approxSymbol = res.data.is_historical ? "~" : "";
const badgeTitle = res.data.is_historical
? `${info.label} (${tr("weather_historical")})`
: info.label;
const opacityStyle = res.data.is_historical
? "opacity: 0.85; font-style: italic;"
: "";
container.innerHTML = `
<div class="pf-weather-badge" title="${badgeTitle}" style="${opacityStyle}">
<span class="pf-weather-icon">${info.icon}</span>
<span>${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C</span>
</div>`;
}
} catch (e) {
console.error("Weather error", e);
}
}
// ============================================================================
// FERMETURE UNIVERSELLE DES MODALES
// ============================================================================
window.addEventListener("click", function (event) {
if (event.target.classList.contains("pf-modal")) {
event.target.style.display = "none";
document.body.classList.remove("no-scroll");
}
});
// --- 1. GESTION DE LA MODALE D'ÉDITION RAPIDE ---
function openHolidayModal(mode) {
const modal = document.getElementById("holidayModal");
const form = document.getElementById("holidayForm");
const btnDelete = document.getElementById("btn_delete");
form.reset();
document.getElementById("inp_id").value = "";
document.getElementById("list_transport").innerHTML = "";
document.getElementById("list_accommodation").innerHTML = "";
document.getElementById("list_activity").innerHTML = "";
if (mode === "add") {
document.getElementById("modalTitle").innerText = tr("hdl_modal_title");
btnDelete.style.display = "none";
} else {
document.getElementById("modalTitle").innerText = tr(
"hdl_quick_edit_title",
);
btnDelete.style.display = "block";
}
modal.style.display = "flex";
setTimeout(() => document.getElementById("inp_title").focus(), 100);
}
function closeHolidayModal() {
document.getElementById("holidayModal").style.display = "none";
document.body.classList.remove("no-scroll");
}
function editHoliday(data) {
const h = data.main;
const modal = document.getElementById("holidayModal");
if (!modal) return;
openHolidayModal("edit");
document.getElementById("inp_id").value = h.id;
document.getElementById("inp_title").value = h.title;
document.getElementById("inp_status").value = h.status;
document.getElementById("inp_period").value = h.period_hint || "";
document.getElementById("inp_start").value = h.start_date || "";
document.getElementById("inp_end").value = h.end_date || "";
document.getElementById("inp_food").value =
h.budget_food > 0 ? h.budget_food : "";
document.getElementById("inp_extra").value =
h.budget_extra > 0 ? h.budget_extra : "";
document.getElementById("inp_notes").value = h.notes || "";
document.getElementById("list_transport").innerHTML = "";
document.getElementById("list_accommodation").innerHTML = "";
document.getElementById("list_activity").innerHTML = "";
if (data.items) {
data.items.forEach((item) => {
if (item.name !== "PF_TECHNICAL_POINT") {
// On passe maintenant l'ID et le lieu à addItem
addItem(
item.category,
item.name,
item.amount,
item.is_paid,
item.id,
item.location_name || "",
);
}
});
}
}
// --- 2. GESTION DES LISTES DYNAMIQUES DANS LA MODALE ---
function addItem(
category,
name = "",
amount = "",
isPaid = 0,
id = "",
location = "",
) {
const container = document.getElementById("list_" + category);
const div = document.createElement("div");
div.className = "savings-line-item"; // Utilisation de ta classe existante
div.style.marginBottom = "10px";
const checkedAttr = isPaid == 1 ? "checked" : "";
// Optimisation : Affichage du badge d'étape si existant
const locationBadge = location
? `<span style="font-size:0.65rem; background:#e2e8f0; padding:2px 6px; border-radius:4px; margin-right:5px; color:#64748b; font-weight:bold;">📍 ${location}</span>`
: "";
div.innerHTML = `
<input type="hidden" name="items[id][]" value="${id}">
<input type="hidden" name="items[cat][]" value="${category}">
<input type="hidden" name="items[location][]" value="${location}">
<div style="flex: 2; display:flex; flex-direction:column; gap:4px;">
${locationBadge}
<input type="text" name="items[name][]" class="pf-input" placeholder="${tr("hdl_js_ph_expense_name")}" value="${name}" style="padding: 8px; font-size:0.9rem;" required>
</div>
<input type="number" step="0.01" name="items[amount][]" class="pf-input" placeholder="0.00" value="${amount}" style="width: 90px; text-align: right; padding: 8px; font-size:0.9rem;">
<label title="${tr("hdl_paid")}" style="display: flex; align-items: center; cursor: pointer; padding: 0 5px;">
<input type="checkbox" ${checkedAttr} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="margin:0;">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span style="font-size:0.75rem; margin-left:4px; font-weight:bold; color:#64748b;">${tr("hdl_paid")}</span>
</label>
<button type="button" onclick="this.parentElement.remove()" class="btn-icon-action delete" title="${tr("btn_delete")}">🗑️</button>
`;
container.appendChild(div);
}
function deleteHoliday() {
if (!confirm(tr("hdl_js_confirm_del_trip"))) return;
const form = document.getElementById("holidayForm");
const input = document.createElement("input");
input.type = "hidden";
input.name = "action_delete";
input.value = "1";
form.appendChild(input);
form.submit();
}
// --- 3. GESTION DE LA CARTE ---
var map = null;
function toggleMap() {
const modal = document.getElementById("hol-map-modal");
if (!modal) return;
if (modal.style.display === "flex") {
modal.style.display = "none";
} else {
modal.style.display = "flex";
setTimeout(initMap, 100);
}
}
function initMap() {
if (map) {
map.invalidateSize();
return;
}
if (typeof L === "undefined") return;
map = L.map("hol-map").setView([46.6, 2.4], 4);
L.tileLayer(
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
{
attribution: "© OpenStreetMap",
},
).addTo(map);
if (typeof HOL_MAP_POINTS !== "undefined") {
HOL_MAP_POINTS.forEach((pt) => {
const color =
pt.status === "planned" || pt.status === "booked" ? "green" : "blue";
L.circleMarker([pt.lat, pt.lng], {
color: color,
radius: 8,
fillOpacity: 0.8,
})
.addTo(map)
.bindPopup(`<b>${pt.title}</b><br>${pt.status}`);
});
}
}
// ============================================================================
// 4. GESTION DE LA CARTE DÉTAILLÉE (ROADTRIP) ET GÉOCODAGE
// ============================================================================
var detailMap = null;
document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById("tripMap")) {
initDetailMap();
}
});
function initDetailMap() {
if (typeof L === "undefined" || typeof MAP_POINTS === "undefined") return;
// 1. 🧹 NETTOYAGE PROPRE : On détruit l'ancienne instance si elle existe (Évite le bug de la souris bloquée)
if (detailMap !== null) {
detailMap.remove();
detailMap = null;
}
const mapContainer = document.getElementById("tripMap");
if (!mapContainer) return;
// 2. 🛡️ BOUCLIER FIREFOX DESKTOP : Empêche le drag natif HTML5 de voler le clic
mapContainer.style.touchAction = "none";
mapContainer.ondragstart = function (e) {
e.preventDefault();
};
// 3. 🛠️ INITIALISATION DE LA CARTE
detailMap = L.map("tripMap", {
tap: false, // Désactive le tap simulé (anti-warning mobile/Firefox)
dragging: true, // Force l'autorisation du déplacement à la souris
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(detailMap);
// Cas : Aucun point
if (MAP_POINTS.length === 0) {
detailMap.setView([46.6, 2.4], 5); // France par défaut
return;
}
const latlngs = [];
const bounds = L.latLngBounds();
// 4. PLACEMENT DES MARQUEURS
MAP_POINTS.forEach((pt, index) => {
const pos = [pt.lat, pt.lng];
latlngs.push(pos);
bounds.extend(pos);
const color = "#2563eb";
const marker = L.circleMarker(pos, {
color: color,
radius: window.innerWidth < 768 ? 6 : 8,
fillOpacity: 1,
fillColor: "white",
weight: 3,
}).addTo(detailMap);
const stepLabel = window.I18N
? window.I18N["hdl_js_step_label"]
: tr("hdl_js_step_label");
marker.bindPopup(`
<div style="text-align:center;">
<div style="font-size:0.75rem; color:#64748b; margin-bottom:2px; font-weight:bold;">${stepLabel} ${index + 1}</div>
<strong style="font-size:1rem; color:#0f172a;">${pt.location_name}</strong><br>
<span style="font-weight:bold; color:${color};">${parseFloat(pt.total_amount).toFixed(2)} €</span>
</div>
`);
// Animation au clic sur le marqueur
marker.on("click", function () {
const card = document.getElementById("step-card-" + pt.sort_order);
if (card) {
card.scrollIntoView({ behavior: "smooth", block: "center" });
card.style.transition = "box-shadow 0.3s, transform 0.3s";
card.style.boxShadow = "0 0 0 3px #3b82f6";
card.style.transform = "scale(1.02)";
setTimeout(() => {
card.style.boxShadow = "";
card.style.transform = "";
}, 1500);
}
});
});
const mapPadding = window.innerWidth < 768 ? [20, 20] : [50, 50];
// 5. CENTRAGE ET TRACÉS (OSRM)
if (latlngs.length === 1) {
detailMap.setView(latlngs[0], 12);
} else if (latlngs.length > 1) {
detailMap.fitBounds(bounds, { padding: mapPadding });
const routePromises = [];
for (let i = 0; i < latlngs.length - 1; i++) {
const startPt = MAP_POINTS[i];
const endPt = MAP_POINTS[i + 1];
const coordsString = `${startPt.lng},${startPt.lat};${endPt.lng},${endPt.lat}`;
const promise = fetch(
`https://router.project-osrm.org/route/v1/driving/${coordsString}?overview=full&geometries=geojson`,
)
.then((response) => response.json())
.then((data) => ({
index: i,
data: data,
coords: [latlngs[i], latlngs[i + 1]],
}))
.catch((err) => ({
index: i,
error: true,
coords: [latlngs[i], latlngs[i + 1]],
}));
routePromises.push(promise);
}
Promise.all(routePromises).then((results) => {
results.sort((a, b) => a.index - b.index);
let returnStartIndex = latlngs.length - 2;
const customReturnStep = MAP_POINTS.findIndex((p) => p.is_return == 1);
if (customReturnStep > 0) {
returnStartIndex = customReturnStep;
}
results.forEach((res) => {
const i = res.index;
let routeColor = "#3b82f6";
let routeWeight = window.innerWidth < 768 ? 4 : 6;
let routeDash = null;
if (i >= returnStartIndex) {
routeColor = "#f97316";
routeWeight = window.innerWidth < 768 ? 3 : 4;
routeDash = "10, 10";
}
if (res.data && res.data.code === "Ok" && res.data.routes.length > 0) {
const routeCoords = res.data.routes[0].geometry.coordinates.map(
(c) => [c[1], c[0]],
);
L.polyline(routeCoords, {
color: routeColor,
weight: routeWeight,
dashArray: routeDash,
opacity: 0.9,
lineCap: "round",
lineJoin: "round",
}).addTo(detailMap);
} else {
drawFallbackLine(res.coords, routeColor, routeWeight);
}
});
});
}
// 6. LANCEMENT DE LA MÉTÉO
if (typeof MAP_POINTS !== "undefined") {
MAP_POINTS.forEach((pt) => {
if (typeof loadWeatherForStep === "function") {
loadWeatherForStep(pt);
}
});
}
// 7. FIX FINAL : Force Leaflet à recalculer sa taille une fois le DOM stabilisé
setTimeout(() => {
if (detailMap) {
detailMap.invalidateSize();
}
}, 300);
// Fonction utilitaire locale
function drawFallbackLine(coords, color, weight) {
L.polyline(coords, {
color: color,
weight: weight || 3,
dashArray: "8, 8",
opacity: 0.7,
}).addTo(detailMap);
}
}
function panMapTo(lat, lng) {
if (detailMap) {
detailMap.setView([lat, lng], 14, { animate: true });
// 🛠️ ERGONOMIE MOBILE : Auto-scroll vers la carte si on est sur petit écran
if (window.innerWidth < 768) {
const mapDiv = document.getElementById("tripMap");
if (mapDiv) {
mapDiv.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}
}
// --- LOGIQUE DE LA MODALE CHECKPOINT ---
function openCheckpointModal(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
container.innerHTML = "";
if (document.getElementById("cp_start_date"))
document.getElementById("cp_start_date").value = "";
if (document.getElementById("cp_end_date"))
document.getElementById("cp_end_date").value = "";
document.getElementById("searchPlaceInput").value = "";
document.getElementById("searchResults").innerHTML = "";
searchBlock.style.display = "block";
if (mode === "add") {
document.getElementById("cpModalTitle").innerText = tr("hdl_btn_add_step");
formBlock.style.display = "none";
btnDel.style.display = "none";
document.getElementById("cp_old_sort_order").value = "";
document.getElementById("cp_name").value = "";
addCpExpenseLine();
document.getElementById("cp_is_return").checked = false;
} else if (mode === "edit" && data) {
document.getElementById("cpModalTitle").innerText = tr("hdl_js_edit_step");
formBlock.style.display = "block";
btnDel.style.display = "block";
document.getElementById("cp_lat").value = data.lat;
document.getElementById("cp_lng").value = data.lng;
document.getElementById("cp_old_sort_order").value = data.sort_order;
document.getElementById("cp_name").value = data.location_name;
document.getElementById("cp_start_date").value = data.step_start_date || "";
document.getElementById("cp_end_date").value = data.step_end_date || "";
document.getElementById("cp_is_return").checked = data.is_return == 1;
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
addCpExpenseLine(
it.category,
it.name,
it.amount,
it.is_paid,
it.notes || "",
it.id || "",
it.item_date || "",
it.item_time || "",
it.duration || 1,
);
visibleCount++;
}
});
if (visibleCount === 0) addCpExpenseLine();
} else {
addCpExpenseLine();
}
}
document.getElementById("checkpointModal").style.display = "flex";
document.body.classList.add("no-scroll");
}
function searchPlace() {
const q = document.getElementById("searchPlaceInput").value.trim();
if (q.length < 3) return;
const resultsDiv = document.getElementById("searchResults");
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">${tr("hdl_js_search_loading")}</span>`;
fetch(
"/modules/holidays/includes/api/geocode.php?limit=5&q=" +
encodeURIComponent(q),
)
.then((res) => res.json())
.then((data) => {
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">${tr("hdl_js_no_result")}</span>`;
return;
}
data.results.forEach((place) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pf-btn btn-secondary";
btn.style.textAlign = "left";
btn.style.padding = "8px";
btn.style.height = "auto";
btn.innerText = "📍 " + place.display_name;
btn.onclick = () =>
selectPlace(place.lat, place.lng, place.display_name);
resultsDiv.appendChild(btn);
});
})
.catch((err) => {
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">${tr("hdl_js_network_error")}</span>`;
});
}
function selectPlace(lat, lng, fullName) {
document.getElementById("cp_lat").value = lat;
document.getElementById("cp_lng").value = lng;
document.getElementById("cp_name").value = fullName.split(",")[0].trim();
document.getElementById("formCheckpoint").style.display = "block";
}
function addCpExpenseLine(
category = "accommodation",
name = "",
amount = "",
isPaid = 0,
notes = "",
itemId = "",
itemDate = "",
itemTime = "",
itemDur = 1,
) {
const container = document.getElementById("cpExpensesContainer");
const div = document.createElement("div");
div.className = "hol-form-row";
const isChecked = isPaid == 1 ? "checked" : "";
div.innerHTML = `
<div class="hol-form-inner">
<select name="items[cat][]" class="pf-input hol-form-select">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${tr("hdl_js_ph_expense_name")}" value="${name}">
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}">
<label class="hol-form-paid-label" title="${tr("hdl_paid")}">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span class="hol-form-paid-text">${tr("hdl_paid")}</span>
</label>
<button type="button" class="btn-icon-action delete btn-remove-expense" onclick="this.parentElement.parentElement.remove()" title="${tr("btn_delete")}">🗑️</button> </div>
<div class="hol-form-subrow">
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input hol-form-notes-full" placeholder="${tr("hdl_ph_notes")}" value="${notes}">
</div>
<input type="hidden" name="items[id][]" value="${itemId}">
<input type="hidden" name="items[date][]" value="${itemDate}">
<input type="hidden" name="items[time][]" value="${itemTime}">
<input type="hidden" name="items[duration][]" value="${itemDur}">
`;
container.appendChild(div);
}
function deleteCheckpoint() {
if (!confirm(tr("hdl_js_confirm_del_step"))) return;
const form = document.getElementById("formCheckpoint");
const input = document.createElement("input");
input.type = "hidden";
input.name = "action_delete";
input.value = "1";
form.appendChild(input);
form.submit();
}
// ============================================================================
// 5. RÉORDONNANCEMENT DES ÉTAPES (DRAG & DROP PC + FLÈCHES MOBILE)
// ============================================================================
// On sort cette fonction pour pouvoir l'appeler depuis les boutons fléchés sur mobile
function saveCheckpointOrder() {
const locations = [
...document.querySelectorAll(".hol-checkpoint-draggable"),
].map((el) => el.getAttribute("data-location"));
const holidayId = document.querySelector('input[name="holiday_id"]').value;
const formData = new FormData();
formData.append("holiday_id", holidayId);
formData.append("locations", JSON.stringify(locations));
fetch("/modules/holidays/includes/api/reorder_checkpoints.php", {
method: "POST",
body: formData,
}).then(() => window.location.reload());
}
// Fonction appelée par les flèches Haut/Bas sur mobile
function moveStepMobile(btn, direction) {
const item = btn.closest(".hol-checkpoint-draggable");
const container = item.parentElement;
if (
direction === -1 &&
item.previousElementSibling &&
item.previousElementSibling.classList.contains("hol-checkpoint-draggable")
) {
container.insertBefore(item, item.previousElementSibling);
saveCheckpointOrder();
} else if (
direction === 1 &&
item.nextElementSibling &&
item.nextElementSibling.classList.contains("hol-checkpoint-draggable")
) {
container.insertBefore(item, item.nextElementSibling.nextElementSibling);
saveCheckpointOrder();
}
}
document.addEventListener("DOMContentLoaded", () => {
const checkpoints = document.querySelectorAll(".hol-checkpoint-draggable");
const container = checkpoints[0]?.parentElement;
if (!container) return;
const isMobile = window.innerWidth <= 768;
let draggedItem = null;
checkpoints.forEach((item) => {
// Si on est sur mobile, on supprime l'attribut draggable pour éviter les conflits de scroll
if (isMobile) {
item.removeAttribute("draggable");
return;
}
item.addEventListener("dragstart", function (e) {
draggedItem = this;
setTimeout(() => (this.style.opacity = "0.4"), 0);
});
item.addEventListener("dragend", function () {
setTimeout(() => {
this.style.opacity = "1";
draggedItem = null;
saveCheckpointOrder();
}, 0);
});
item.addEventListener("dragover", function (e) {
e.preventDefault();
const afterElement = getDragAfterElement(container, e.clientY);
if (afterElement == null) {
container.appendChild(draggedItem);
} else {
container.insertBefore(draggedItem, afterElement);
}
});
});
function getDragAfterElement(container, y) {
const draggableElements = [
...container.querySelectorAll(
'.hol-checkpoint-draggable:not([style*="opacity: 0.4"])',
),
];
return draggableElements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
},
{ offset: Number.NEGATIVE_INFINITY },
).element;
}
});
// ============================================================================
// MOTEUR DRAG & DROP DU PLANNING
// ============================================================================
function closePlanningModal() {
document.getElementById("planningModal").style.display = "none";
document.body.classList.remove("no-scroll");
}
function openPlanningModal(step) {
document.getElementById("planningModalTitle").innerText =
tr("hdl_planning_title") + " : " + step.location_name;
const container = document.getElementById("planningContainer");
selectedItemIdForMove = null;
let validItems = step.items.filter((it) => it.name !== "PF_TECHNICAL_POINT");
if (!step.step_start_date || !step.step_end_date) {
container.innerHTML = `<div style="text-align:center; padding:40px;"><h3>${tr("hdl_js_missing_dates_title")}</h3><p style="color:#64748b;">${tr("hdl_js_missing_dates_msg")}</p></div>`;
document.getElementById("planningModal").style.display = "flex";
return;
}
let datesToDisplay = [];
let curr = new Date(step.step_start_date);
let end = new Date(step.step_end_date);
while (curr <= end) {
datesToDisplay.push(curr.toISOString().split("T")[0]);
curr.setDate(curr.getDate() + 1);
}
let html = `
<div class="hol-planning-layout">
<div class="hol-unmapped-zone" id="unmapped-pool"
ondragover="allowDrop(event)" ondrop="handleDropEvent(event, '', '')"
onclick="handleZoneTap(event, '', '')">
<div class="hol-unmapped-title" style="width:100%;">📥 ${tr("hdl_to_place")}</div>
</div>
<div class="hol-calendar-zone">
`;
datesToDisplay.forEach((dateStr) => {
const dObj = new Date(dateStr);
const dayName = dObj.toLocaleDateString(currentLang, { weekday: "short" });
const dayNum = dObj.toLocaleDateString(currentLang, {
day: "numeric",
month: "short",
});
html += `
<div class="hol-day-column">
<div class="hol-calendar-day-header">
<div class="hol-cal-weekday">${dayName}</div>
<div class="hol-cal-date">${dayNum}</div>
<div id="plan-weather-${dateStr}" style="margin-top: 5px; display: flex; justify-content: center; min-height: 20px;"></div>
</div>
<div class="hol-time-slots-container">
`;
for (let h = 8; h <= 22; h++) {
let hourStr = h.toString().padStart(2, "0") + ":00";
html += `
<div class="hol-time-slot" data-date="${dateStr}" data-time="${hourStr}"
ondragover="allowDrop(event)" ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
ondrop="handleDropEvent(event, '${dateStr}', '${hourStr}')"
onclick="handleZoneTap(event, '${dateStr}', '${hourStr}')">
<span class="hol-slot-label">${hourStr}</span>
</div>
`;
}
html += `</div></div>`;
});
html += `</div></div>`;
container.innerHTML = html;
// 1. On détecte si on est sur mobile juste avant la boucle
const isMobile = window.innerWidth <= 768;
const dragAttr = isMobile ? "" : 'draggable="true"';
validItems.forEach((it) => {
let icon = "🏷️";
let catClass = "cat-activity";
if (it.category === "accommodation") {
icon = "🏨";
catClass = "cat-accommodation";
}
if (it.category === "transport") {
icon = "🚗";
catClass = "cat-transport";
}
const dur = it.duration || 1;
const noteHtml = it.notes
? `<div class="hol-drag-note">${it.notes}</div>`
: "";
// 2. MODIFICATION ICI : On remplace le texte en dur draggable="true" par la variable ${dragAttr}
const elHtml = `
<div class="hol-drag-item ${catClass}" ${dragAttr}
id="drag-item-${it.id}" data-id="${it.id}"
style="--duration: ${dur};"
ondragstart="dragStart(event)" onclick="handleItemTap(event, ${it.id})">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 5px;">
<div class="hol-drag-title" style="flex:1;">${icon} ${it.name}</div>
<div class="hol-item-duration-controls">
<button class="hol-dur-btn" onclick="changeDuration(event, ${it.id}, -1)">-</button>
<span class="hol-dur-text" id="dur-text-${it.id}">${dur}h</span>
<button class="hol-dur-btn" onclick="changeDuration(event, ${it.id}, 1)">+</button>
</div>
</div>
${noteHtml}
</div>
`;
if (it.item_date && it.item_time && datesToDisplay.includes(it.item_date)) {
const hourPrefix = it.item_time.substring(0, 2) + ":00";
const targetSlot = container.querySelector(
`.hol-time-slot[data-date="${it.item_date}"][data-time="${hourPrefix}"]`,
);
if (targetSlot) {
targetSlot.insertAdjacentHTML("beforeend", elHtml);
return;
}
}
document
.getElementById("unmapped-pool")
.insertAdjacentHTML("beforeend", elHtml);
});
document.getElementById("planningModal").style.display = "flex";
document.body.classList.add("no-scroll");
datesToDisplay.forEach((dateStr) => {
loadWeatherForPlanning(step.lat, step.lng, dateStr);
});
}
function changeDuration(e, itemId, delta) {
e.stopPropagation();
const itemEl = document.getElementById("drag-item-" + itemId);
let currentDur = parseInt(itemEl.style.getPropertyValue("--duration")) || 1;
let newDur = currentDur + delta;
if (newDur < 1) newDur = 1;
if (newDur > 12) newDur = 12;
itemEl.style.setProperty("--duration", newDur);
document.getElementById(`dur-text-${itemId}`).innerText = newDur + "h";
updateItemMemory(itemId, { duration: newDur });
const formData = new FormData();
formData.append("action", "update_item_duration");
formData.append("item_id", itemId);
formData.append("duration", newDur);
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
method: "POST",
body: formData,
});
}
function handleItemTap(e, itemId) {
e.stopPropagation();
document
.querySelectorAll(".hol-drag-item")
.forEach((el) => el.classList.remove("selected-for-move"));
if (selectedItemIdForMove === itemId) {
selectedItemIdForMove = null;
} else {
selectedItemIdForMove = itemId;
document
.getElementById("drag-item-" + itemId)
.classList.add("selected-for-move");
}
}
function handleZoneTap(e, dateStr, timeStr) {
if (selectedItemIdForMove) {
handleDropLogic(selectedItemIdForMove, dateStr, timeStr, e.currentTarget);
selectedItemIdForMove = null;
document
.querySelectorAll(".hol-drag-item")
.forEach((el) => el.classList.remove("selected-for-move"));
}
}
function dragStart(e) {
e.dataTransfer.setData("text/plain", e.target.id);
e.dataTransfer.effectAllowed = "move";
}
function allowDrop(e) {
e.preventDefault();
}
function dragEnter(e) {
e.preventDefault();
let s = e.target.closest(".hol-time-slot");
if (s) s.classList.add("drag-over");
}
function dragLeave(e) {
let s = e.target.closest(".hol-time-slot");
if (s) s.classList.remove("drag-over");
}
function handleDropEvent(e, dateStr, timeStr) {
e.preventDefault();
let slot = e.target.closest(".hol-time-slot");
if (slot) slot.classList.remove("drag-over");
const idStr = e.dataTransfer.getData("text/plain");
const itemId = idStr.replace("drag-item-", "");
const dropZone = slot || document.getElementById("unmapped-pool");
handleDropLogic(itemId, dateStr, timeStr, dropZone);
}
function handleDropLogic(itemId, dateStr, timeStr, dropZone) {
const draggedEl = document.getElementById("drag-item-" + itemId);
if (dropZone && draggedEl) {
dropZone.appendChild(draggedEl);
updateItemMemory(itemId, { item_date: dateStr, item_time: timeStr });
const formData = new FormData();
formData.append("action", "update_item_datetime");
formData.append("item_id", itemId);
formData.append("item_date", dateStr);
formData.append("item_time", timeStr);
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
method: "POST",
body: formData,
});
}
}
function updateItemMemory(itemId, changes) {
MAP_POINTS.forEach((step) => {
let item = step.items.find((i) => i.id == itemId);
if (item) Object.assign(item, changes);
});
}
function saveItemDateTime(itemId, dateStr, timeStr) {
const formData = new FormData();
formData.append("action", "update_item_datetime");
formData.append("item_id", itemId);
formData.append("item_date", dateStr);
formData.append("item_time", timeStr);
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
method: "POST",
body: formData,
}).catch((err) => console.error("Erreur:", err));
}
// ============================================================================
// MÉTÉO SPÉCIFIQUE AU HEADER DU PLANNING
// ============================================================================
async function loadWeatherForPlanning(lat, lng, dateStr) {
const container = document.getElementById(`plan-weather-${dateStr}`);
if (!container || !lat || !lng) return;
try {
const resp = await fetch(
`/modules/holidays/includes/api/get_weather.php?lat=${lat}&lng=${lng}&date=${dateStr}`,
);
const res = await resp.json();
if (res.success) {
const info = getWeatherInfo(res.data.code);
const approxSymbol = res.data.is_historical ? "~" : "";
container.innerHTML = `
<div class="pf-weather-badge" style="font-size: 0.65rem; padding: 2px 6px; ${res.data.is_historical ? "opacity: 0.8;" : ""}" title="${info.label}">
<span class="pf-weather-icon">${info.icon}</span>
<span>${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C</span>
</div>
`;
}
} catch (e) {
console.error("Erreur météo planning", e);
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
// modules/holidays/includes/api/geocode.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');
// 1. Validation de l'entrée
$q = trim($_GET['q'] ?? '');
if ($q === '') {
http_response_code(400);
echo json_encode(['error' => 'missing_q']);
exit;
}
$limit = (int)($_GET['limit'] ?? 1);
if ($limit < 1) $limit = 1;
if ($limit > 10) $limit = 10;
// 2. Normalisation pour le cache
$qNorm = mb_strtolower($q);
$qHash = hash('sha256', $qNorm);
try {
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_geocode_cache (
q_hash CHAR(64) PRIMARY KEY,
q VARCHAR(255),
lat DECIMAL(10, 7),
lng DECIMAL(10, 7),
display_name TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {}
// 3. Vérification du cache
try {
$st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?");
$st->execute([$qHash]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
if ($limit === 1) {
echo json_encode([
'lat' => (float)$row['lat'],
'lng' => (float)$row['lng'],
'display_name' => $row['display_name'],
'cached' => true
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
echo json_encode(['results' => [[
'lat' => (float)$row['lat'],
'lng' => (float)$row['lng'],
'display_name' => $row['display_name'],
'cached' => true
]]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
} catch (Throwable $e) {}
// 4. NOUVELLE API : Appel à Photon (Komoot) - Beaucoup plus tolérant pour les adresses !
$endpoint = 'https://photon.komoot.io/api/';
$params = http_build_query([
'q' => $q,
'limit' => $limit,
'lang' => 'fr' // On force les résultats en français
], '', '&', PHP_QUERY_RFC3986);
$url = $endpoint . '?' . $params;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => 'HouseHub-Holidays/1.0',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $http !== 200) {
http_response_code(502);
echo json_encode(['error' => 'geocode_failed']);
exit;
}
$data = json_decode($body, true);
if (!isset($data['features']) || empty($data['features'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
exit;
}
// 5. Formatage des résultats (Photon renvoie les données différemment de Nominatim)
$results = [];
foreach ($data['features'] as $feature) {
$prop = $feature['properties'];
$coords = $feature['geometry']['coordinates']; // [lng, lat]
// Construction propre du nom (Nom du lieu + Rue + Ville + Pays)
$nameParts = [];
if (!empty($prop['name'])) $nameParts[] = $prop['name'];
if (!empty($prop['housenumber']) && !empty($prop['street'])) {
$nameParts[] = $prop['housenumber'] . ' ' . $prop['street'];
} elseif (!empty($prop['street'])) {
$nameParts[] = $prop['street'];
}
if (!empty($prop['city'])) $nameParts[] = $prop['city'];
elseif (!empty($prop['town'])) $nameParts[] = $prop['town'];
elseif (!empty($prop['village'])) $nameParts[] = $prop['village'];
if (!empty($prop['country'])) $nameParts[] = $prop['country'];
$displayName = implode(', ', array_unique($nameParts));
$results[] = [
'lat' => round((float)$coords[1], 6),
'lng' => round((float)$coords[0], 6),
'display_name' => $displayName
];
}
// 6. Mise en cache du premier résultat
if (isset($results[0])) {
try {
$st = $pdo->prepare("
INSERT INTO pf_geocode_cache (q_hash, q, lat, lng, display_name, updated_at)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE lat=VALUES(lat), lng=VALUES(lng), display_name=VALUES(display_name), updated_at=NOW()
");
$st->execute([$qHash, $q, $results[0]['lat'], $results[0]['lng'], $results[0]['display_name']]);
} catch (Throwable $e) {}
}
// 7. Retour JSON pour ton Javascript
if ($limit === 1) {
echo json_encode($results[0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
@@ -0,0 +1,41 @@
<?php
// modules/holidays/includes/api/get_holiday_data.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');
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
http_response_code(400);
echo json_encode(['error' => 'invalid_id']);
exit;
}
try {
// Si tu utilises pf_holidays au lieu de pf_holidays_ideas, change le nom de la table ici !
$st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?");
$st->execute([$id]);
$it = $st->fetch(PDO::FETCH_ASSOC);
if (!$it) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
exit;
}
$it['id'] = (int)$it['id'];
if (isset($it['lat'])) $it['lat'] = (float)$it['lat'];
if (isset($it['lng'])) $it['lng'] = (float)$it['lng'];
if (isset($it['ideal_days'])) $it['ideal_days'] = (int)$it['ideal_days'];
echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'server_error']);
}
@@ -0,0 +1,127 @@
<?php
// modules/holidays/includes/api/get_weather.php
ini_set('display_errors', 0);
error_reporting(0);
$basePath = '../../../../';
require_once $basePath . 'includes/db.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once $basePath . 'includes/auth.php';
header('Content-Type: application/json');
$lat = filter_input(INPUT_GET, 'lat', FILTER_VALIDATE_FLOAT);
$lng = filter_input(INPUT_GET, 'lng', FILTER_VALIDATE_FLOAT);
$date = $_GET['date'] ?? null;
if (!$lat || !$lng || !$date) {
echo json_encode(['success' => false, 'message' => 'Paramètres manquants']);
exit;
}
$date = substr($date, 0, 10);
$today = new DateTime();
$targetDate = new DateTime($date);
$interval = $today->diff($targetDate);
$daysDiff = (int)$interval->format('%R%a');
// --- 🧠 GESTION INTELLIGENTE DU TEMPS (Prévisions vs Historique) ---
if ($daysDiff > 16) {
// 1. VOYAGE LOINTAIN : Calcul de la moyenne sur 3 ans
$currentYear = (int)$today->format('Y');
$monthDay = $targetDate->format('m-d');
if ($monthDay === '02-29') $monthDay = '02-28'; // Sécurité année bissextile
$tempMaxSum = 0;
$tempMinSum = 0;
$validYearsCount = 0;
$representativeCode = 0; // On gardera le code de l'année N-1
for ($i = 1; $i <= 3; $i++) {
$pastYear = $currentYear - $i;
$searchDate = $pastYear . '-' . $monthDay;
$url = "https://archive-api.open-meteo.com/v1/archive?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 2); // Timeout court pour ne pas ralentir le serveur
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
curl_close($ch);
if ($res) {
$data = json_decode($res, true);
if (isset($data['daily']['temperature_2m_max'][0])) {
$tempMaxSum += $data['daily']['temperature_2m_max'][0];
$tempMinSum += $data['daily']['temperature_2m_min'][0];
if ($i === 1) { // On prend le temps qu'il a fait à N-1 pour l'icône
$representativeCode = $data['daily']['weather_code'][0];
}
$validYearsCount++;
}
}
}
// Si on a pu récupérer au moins une année de données
if ($validYearsCount > 0) {
echo json_encode([
'success' => true,
'data' => [
'code' => $representativeCode,
'temp_max' => round($tempMaxSum / $validYearsCount, 1), // Moyenne arrondie à 1 décimale
'temp_min' => round($tempMinSum / $validYearsCount, 1),
'is_historical' => true
]
]);
} else {
echo json_encode(['success' => false, 'message' => 'Archives indisponibles']);
}
exit; // On arrête l'exécution ici pour les voyages lointains
} elseif ($daysDiff < -2) {
// 2. VOYAGE PASSÉ : On interroge les archives pour cette date précise
$baseUrl = "https://archive-api.open-meteo.com/v1/archive";
$searchDate = $date;
} else {
// 3. FUTUR PROCHE (Prévisions fiables) : On interroge les prévisions
$baseUrl = "https://api.open-meteo.com/v1/forecast";
$searchDate = $date;
}
// --- APPEL API CLASSIQUE (Pour les prévisions et les voyages passés) ---
$url = "$baseUrl?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$res = curl_exec($ch);
curl_close($ch);
if (!$res) {
echo json_encode(['success' => false, 'message' => 'Erreur API Open-Meteo']);
exit;
}
$data = json_decode($res, true);
if (isset($data['daily']['weather_code'][0])) {
echo json_encode([
'success' => true,
'data' => [
'code' => $data['daily']['weather_code'][0],
'temp_max' => $data['daily']['temperature_2m_max'][0],
'temp_min' => $data['daily']['temperature_2m_min'][0],
'is_historical' => false
]
]);
} else {
echo json_encode(['success' => false, 'message' => 'Aucune donnée pour cette date']);
}
@@ -0,0 +1,27 @@
<?php
// modules/holidays/includes/api/reorder_checkpoints.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
$holiday_id = (int)$_POST['holiday_id'];
$locations = json_decode($_POST['locations'] ?? '[]', true);
if ($holiday_id > 0 && is_array($locations)) {
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET sort_order = ? WHERE holiday_id = ? AND location_name = ?");
foreach ($locations as $index => $loc) {
// On met à jour toutes les dépenses de ce lieu avec son nouveau rang (0, 1, 2...)
$stmt->execute([$index, $holiday_id, $loc]);
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
}
@@ -0,0 +1,107 @@
<?php
// modules/holidays/includes/api/save_checkpoint.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
// INTERCEPTION AJAX : Sauvegarde du planning (Drag & Drop / Durée)
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration'])) {
$itemId = (int)$_POST['item_id'];
if ($_POST['action'] === 'update_item_datetime') {
$itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
$itemTime = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?");
$stmt->execute([$itemDate, $itemTime, $itemId]);
} else {
$duration = (int)$_POST['duration'];
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?");
$stmt->execute([$duration, $itemId]);
}
echo json_encode(['success' => true]);
exit; // Crucial : on arrête le script ici !
}
$holiday_id = (int)$_POST['holiday_id'];
$location_name = trim($_POST['location_name']);
$lat = (float)$_POST['lat'];
$lng = (float)$_POST['lng'];
$old_sort_order = isset($_POST['old_sort_order']) && $_POST['old_sort_order'] !== '' ? (int)$_POST['old_sort_order'] : null;
// 1. SUPPRESSION
if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') {
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
exit;
}
if ($holiday_id > 0 && !empty($location_name)) {
try {
$pdo->beginTransaction();
// 2. DÉTERMINER L'ORDRE (Identifiant de l'étape)
if ($old_sort_order !== null) {
// Modification : on supprime l'ancien contenu de cette étape précise
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
$target_order = $old_sort_order;
} else {
// Ajout : on place à la fin (Max + 1)
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
$stmtMax->execute([$holiday_id]);
$max = $stmtMax->fetchColumn();
$target_order = ($max !== null) ? (int)$max + 1 : 0;
}
// Récupération des dates de l'étape globale
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
$step_end = !empty($_POST['step_end_date']) ? $_POST['step_end_date'] : null;
$is_return = isset($_POST['is_return']) ? 1 : 0; // NOUVEAU
// 3. INSERTION DES LIGNES (16 Colonnes)
$stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, notes, item_date, item_time, step_start_date, step_end_date, duration, is_return) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$validItemsCount = 0;
if (isset($_POST['items']['name'])) {
foreach ($_POST['items']['name'] as $i => $raw_name) {
$name = trim($raw_name);
$amount_raw = $_POST['items']['amount'][$i];
if ($name !== '' || $amount_raw !== '') {
$cat = $_POST['items']['cat'][$i] ?? 'activity';
$amount = (float)$amount_raw;
$paid = (isset($_POST['items']['paid'][$i]) && (int)$_POST['items']['paid'][$i] === 1) ? 1 : 0;
$note = trim($_POST['items']['notes'][$i] ?? '');
$date = !empty($_POST['items']['date'][$i]) ? $_POST['items']['date'][$i] : null;
$time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null;
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
// Ajout de $is_return à la fin
$stmt->execute([$holiday_id, $cat, $name ?: tr('hdl_default_exp_name'), $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $is_return]);
$validItemsCount++;
}
}
}
if ($validItemsCount === 0) {
$stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng, $target_order, '', null, null, $step_start, $step_end, 1, $is_return]);
}
// 4. GESTION DES FAVORIS
if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') {
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
$favs = json_decode($stmtFav->fetchColumn() ?: '[]', true);
$exists = false;
foreach ($favs as $f) { if ($f['name'] === $location_name) $exists = true; }
if (!$exists) {
$favs[] = ['name' => $location_name, 'lat' => $lat, 'lng' => $lng];
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('holiday_favorites', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")->execute([json_encode($favs)]);
}
}
$pdo->commit();
} catch (Exception $e) { $pdo->rollBack(); die($e->getMessage()); }
}
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
exit;

Some files were not shown because too many files have changed in this diff Show More