diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 7ee0e15..39f0ffc 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -212,7 +212,45 @@ Regles UI : - en vue mensuelle, les evenements multi-jours s'affichent comme une barre horizontale et ne sont pas dupliques dans chaque cellule ; - les vues hebdomadaire et mensuelle affichent un indicateur discret du moment actuel. - en vue hebdomadaire, un switch standard peut masquer les longues plages horaires vides ; la tranche masquée reste indiquée dans la grille, pas dans un texte qui déborde de la colonne horaire. -- le calendrier supporte l'import/export texte via le panneau d'ajout, pour les vues hebdomadaire et mensuelle. +- le calendrier supporte l'import/export texte via le toggle import/export du header, pour les vues hebdomadaire et mensuelle. + +### Arbre D'objectifs + +L'arbre d'objectifs représente des chaînes de craft, de farm ou de prérequis sous forme d'arborescence dépliable. + +Regles UI : + +- utiliser le ratio `tool-split-*` 35 / 65 quand l'édition est visible ; +- le panneau gauche édite uniquement le nœud sélectionné via les onglets `Base`, `Contenu` quand le type le nécessite, et `Avancé` ; +- le panneau gauche ne doit pas porter les boutons d'ajout racine ou sous-étape ; l'ajout racine se fait via un bouton dans le schéma, long en haut en mode vertical et haut/étroit à gauche en mode horizontal, et les sous-étapes via le bouton `+` des cards ; +- l'onglet `Base` contient le type `Étape`, `Texte`, `Calculateur` ou `Checklist`, puis le titre ; pour une étape, il expose aussi l'icône et la couleur d'icône ; +- l'onglet `Contenu` est réservé aux types `Texte`, `Calculateur` et `Checklist` ; les étapes ne doivent pas afficher cet onglet ; +- l'onglet `Avancé` contient le reset par nœud ; pour une étape seulement, il ajoute les sections `Quantité` et `Progression`, avec un switch où l'état actif correspond à la progression automatique par les enfants ; +- le switch de progression expose une aide courte sous le contrôle afin de différencier calcul automatique et mise à jour manuelle ; +- une étape dont tous les enfants directs ont une cible de `1` se comporte par défaut en mise à jour manuelle, sauf choix explicite du mode automatique ; +- le panneau droit reste la source visuelle principale, avec branches compactes, liaisons SVG mesurées et barres de progression ; +- le panneau droit expose un switch avec icône `rotate` pour alterner entre organisation horizontale et verticale ; l'orientation est persistée par outil ; +- les liaisons entre nœuds doivent être dessinées par une couche dédiée basée sur les positions réelles des cartes, pas par des pseudo-éléments CSS dépendants de largeurs fixes ; +- une card peut avoir plusieurs prérequis visibles : elle garde un parent principal pour le placement, mais des liaisons supplémentaires peuvent converger vers elle ; +- tous les enfants restent rendus comme des nœuds d'organigramme, même sans sous-enfant, afin que l'ajout ultérieur d'un enfant ne change pas brutalement la structure ; +- l'ouverture d'une branche doit replier les branches sœurs du même niveau et leurs descendants, afin de limiter la largeur déployée en mode deux colonnes ; +- quand une branche ouverte contient un objectif à plusieurs parents, les branches nécessaires aux autres parents visibles doivent rester ouvertes afin que la convergence reste compréhensible ; +- les nœuds affichent une icône principale et révèlent leur titre via `Tooltip`, afin de garder une lecture proche organigramme ; +- les types `Texte`, `Calculateur` et `Checklist` sont des mini-contenus internes au nœud : leurs lignes doivent être visibles directement dans la carte, avec une zone d'ajout compacte ; +- les cards de liste doivent laisser leur preview interne occuper la largeur disponible de la card, sans largeur fixe plus étroite que la carte ; +- ces mini-contenus ne remplacent pas les sous-objectifs : les sous-objectifs restent des enfants d'organigramme séparés ; +- les lignes `Checklist` suivent le comportement de l'outil checklist : cible `1` affiche une checkbox ; cible supérieure à `1` affiche une quantité avec contrôles `-` et `+` ; +- les lignes `Calculateur` exposent une formule et un résultat ; la variable `base` référence la cible calculée du parent direct ; +- les cards peuvent exposer des actions secondaires au hover/focus, notamment modification et suppression, sans les rendre visibles au repos ; +- le bouton `+` d'une card ajoute toujours une sous-étape ; les nœuds `Texte`, `Calculateur` et `Checklist` exposent un bouton long dédié sous leur preview pour ajouter une entrée interne ; +- le bouton long d'entrée interne doit avoir une tooltip explicite, car son libellé visible reste volontairement compact ; +- l'accent de couleur d'un nœud doit s'appliquer à toute la card, y compris au hover, sans bordure latérale d'une couleur différente ; +- la card sélectionnée en mode édition doit être clairement identifiable par un contour et un halo plus présents que l'état sélectionné en lecture ; +- le drag & drop des nœuds doit afficher son indicateur selon l'orientation de la liste : gauche/droite en horizontal, haut/bas en vertical ; +- les actions de nœud utilisent des boutons icônes : ajout, repli, suppression, déplacement ; +- le bouton de déplacement doit rester clairement visible sur les cards compactes, notamment les nœuds `Texte` et `Checklist` ; +- la progression d'un parent doit rester agrégée et lisible sans afficher une formule détaillée dans la carte ; +- en panneau latéral ou mobile, l'arbre passe en une colonne sans scroll horizontal. ### Combos @@ -320,6 +358,18 @@ Regles : ## 11. Boutons +### Selects Des Outils + +Tous les ` setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} /> + + )} + {textExchangeOpen && ( +
context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
)} diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 7de0c92..aa3bb53 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -21,7 +21,7 @@ import { setComboItemCategory } from "./combosUtils.js"; -export function CombosModule({ toolboxId, moduleId, context, editing }) { +export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) { const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] })); const textContent = context.moduleText?.combos || {}; const [comboCategory, setComboCategory] = useState(""); @@ -589,9 +589,11 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { - {editing && !editingComboId && ( - save(nextData)} /> - )} + + )} + {textExchangeOpen && ( +
+ save(nextData)} />
)} diff --git a/website/src/features/toolboxes/modules/CountersModule.jsx b/website/src/features/toolboxes/modules/CountersModule.jsx index 9e380e1..9b56416 100644 --- a/website/src/features/toolboxes/modules/CountersModule.jsx +++ b/website/src/features/toolboxes/modules/CountersModule.jsx @@ -4,7 +4,7 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; -export function CountersModule({ toolboxId, moduleId, context, editing }) { +export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) { const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] })); const textContent = context.moduleText?.counters || {}; const counterDraft = useDraftForm({ label: "" }); @@ -50,6 +50,10 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) { + + )} + {textExchangeOpen && ( +
context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
)} diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx index bf5cd8a..dfa52e1 100644 --- a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx @@ -7,7 +7,7 @@ import { EquipmentItem } from "./EquipmentPlannerItem.jsx"; import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx"; import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx"; -export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) { +export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) { const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] })); const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]); const textContent = context.moduleText?.equipmentPlanner || {}; @@ -112,6 +112,10 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing } setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} /> + + )} + {textExchangeOpen && ( +
save(nextData)} />
)} diff --git a/website/src/features/toolboxes/modules/GoalTreeModule.jsx b/website/src/features/toolboxes/modules/GoalTreeModule.jsx new file mode 100644 index 0000000..a12ada5 --- /dev/null +++ b/website/src/features/toolboxes/modules/GoalTreeModule.jsx @@ -0,0 +1,1020 @@ +// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte. +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; +import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx"; +import { Icon } from "../../../components/Icon.jsx"; +import { Tabs } from "../../../components/Tabs.jsx"; +import { Tooltip } from "../../../components/Tooltip.jsx"; +import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; +import { + GOAL_TREE_COLORS, + GOAL_TREE_COLOR_VALUES, + GOAL_TREE_ICON_NAMES, + GOAL_TREE_KINDS, + GOAL_TREE_RESET_TYPES, + GOAL_TREE_WEEK_DAYS, + applyGoalTreeDueResets, + evaluateGoalTreeNodes, + formatGoalTreeNumber, + getGoalTreeChildren, + getGoalTreeDescendantIds, + getGoalTreeParentIds, + getGoalTreeParentMap +} from "./goalTreeUtils.js"; + +const KIND_LABELS = { + step: "Étape", + text: "Texte", + checklist: "Checklist" +}; + +const RESET_LABELS = { + none: "Aucun", + daily: "Quotidien", + weekly: "Hebdomadaire" +}; + +const KIND_ICONS = { + step: "diagram", + text: "notepad", + checklist: "checklist" +}; + +const GOAL_TREE_VIEW_SCALE = 0.7; + +function getGoalTreeKindIcon(kind) { + return KIND_ICONS[kind] || "diagram"; +} + +function getGoalTreeNodeIcon(node) { + return node.kind === "step" ? node.icon || "cube" : getGoalTreeKindIcon(node.kind); +} + +function getGoalTreeNodeColor(node) { + return node.kind === "step" ? node.color || "gold" : "cyan"; +} + +function getDefaultGoalTreeItem(kind, context, textContent = {}, parentId = "") { + return { + id: context.uid("goalItem"), + label: textContent.itemLabelPlaceholder || "Élément", + formula: kind === "checklist" ? (parentId ? "base" : "1") : undefined, + qtyTarget: kind === "checklist" ? 1 : undefined, + qtyCurrent: kind === "checklist" ? 0 : undefined + }; +} + +function getGoalTreeNodeTooltip(node) { + return `${node.title} - ${formatGoalTreeNumber(node.progress.current)} / ${formatGoalTreeNumber(node.progress.target)}`; +} + +function isSingleValueGoalTreeNode(node) { + return Math.max(1, node?.progress?.target || node?.computedQtyTarget || node?.qtyTarget || 1) <= 1; +} + +function getEffectiveGoalTreeProgressMode(node, children = []) { + if (node?.kind !== "step") return "auto"; + if (node.progressMode) return node.progressMode; + if (!children.length) return "manual"; + return children.every(isSingleValueGoalTreeNode) ? "manual" : "auto"; +} + +function getVisibleGoalTreeNodeIds(nodes, parentId = "", ancestorVisible = true) { + if (!ancestorVisible) return new Set(); + return getGoalTreeChildren(nodes, parentId).reduce((visibleIds, node) => { + visibleIds.add(node.id); + getVisibleGoalTreeNodeIds(nodes, node.id, !node.collapsed).forEach((nodeId) => visibleIds.add(nodeId)); + return visibleIds; + }, new Set()); +} + +function getVisibleGoalTreeEdges(nodes) { + const visibleNodeIds = getVisibleGoalTreeNodeIds(nodes); + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + const primaryEdges = nodes.flatMap((node) => { + if (!visibleNodeIds.has(node.id) || node.collapsed) return []; + const children = getGoalTreeChildren(nodes, node.id); + return children.filter((child) => visibleNodeIds.has(child.id)).map((child) => ({ from: node.id, to: child.id })); + }); + const linkedEdges = nodes.flatMap((node) => getGoalTreeParentIds(node) + .filter((parentId) => parentId !== node.parentId) + .filter((parentId) => visibleNodeIds.has(parentId) && visibleNodeIds.has(node.id) && !nodeMap.get(parentId)?.collapsed) + .map((parentId) => ({ from: parentId, to: node.id, grouped: false }))); + return [...primaryEdges, ...linkedEdges]; +} + +function getGoalTreeAncestorIds(nodes, nodeId) { + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + const ancestors = new Set(); + let current = nodeMap.get(nodeId); + while (current?.parentId && !ancestors.has(current.parentId)) { + ancestors.add(current.parentId); + current = nodeMap.get(current.parentId); + } + return ancestors; +} + +function getGoalTreeConvergenceKeepOpenIds(nodes, targetId) { + const targetBranchIds = [targetId, ...getGoalTreeDescendantIds(nodes, targetId)]; + const keepOpenIds = new Set(); + targetBranchIds.forEach((nodeId) => { + const node = nodes.find((entry) => entry.id === nodeId); + getGoalTreeParentIds(node) + .filter((parentId) => parentId !== node?.parentId) + .forEach((parentId) => { + keepOpenIds.add(parentId); + getGoalTreeAncestorIds(nodes, parentId).forEach((ancestorId) => keepOpenIds.add(ancestorId)); + }); + }); + return keepOpenIds; +} + +function getGoalTreeLinkedControlIds(nodes, parentId) { + const controlledNodes = nodes.filter((node) => getGoalTreeParentIds(node).includes(parentId)); + return new Set([ + parentId, + ...controlledNodes.flatMap((node) => getGoalTreeParentIds(node)) + ]); +} + +export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) { + const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] })); + const textContent = context.moduleText?.goalTree || {}; + const treeOrientation = data.orientation || "horizontal"; + const verticalView = treeOrientation === "vertical"; + const reorderOrientation = treeOrientation === "horizontal" ? "vertical" : "horizontal"; + const viewScale = editing ? GOAL_TREE_VIEW_SCALE : 1; + const evaluatedNodes = useMemo(() => evaluateGoalTreeNodes(data.nodes), [data.nodes]); + const evaluatedData = useMemo(() => ({ ...data, nodes: evaluatedNodes }), [data, evaluatedNodes]); + const evaluatedNodeMap = useMemo(() => new Map(evaluatedNodes.map((node) => [node.id, node])), [evaluatedNodes]); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const treeViewRef = useRef(null); + const treeEdges = useMemo(() => getVisibleGoalTreeEdges(evaluatedNodes), [evaluatedNodes]); + const storageReady = context.storageReady !== false; + const connectorTopologyKey = useMemo(() => [ + treeOrientation, + treeEdges.map((edge) => `${edge.from}>${edge.to}`).join("|"), + evaluatedNodes.map((node) => `${node.id}:${node.kind}:${node.collapsed ? 1 : 0}:${node.progress.ratio}`).join("|") + ].join("::"), [evaluatedNodes, treeEdges, treeOrientation]); + const [connectorRefreshTick, setConnectorRefreshTick] = useState(0); + const connectorRefreshKey = `${storageReady ? "ready" : "loading"}::${connectorTopologyKey}::${connectorRefreshTick}`; + const [selectedNodeId, setSelectedNodeId] = useState(() => data.nodes[0]?.id || ""); + const [activeFormTab, setActiveFormTab] = useState("base"); + const selectedNode = evaluatedNodes.find((node) => node.id === selectedNodeId) || evaluatedNodes[0] || null; + const selectedNodeIsStep = selectedNode?.kind === "step"; + const selectedNodeChildren = selectedNode ? getGoalTreeChildren(evaluatedNodes, selectedNode.id) : []; + const selectedProgressMode = selectedNode ? getEffectiveGoalTreeProgressMode(selectedNode, selectedNodeChildren) : "auto"; + const formTabs = [ + { id: "base", label: textContent.baseTab || "Base", icon: "cube" }, + ...(!selectedNodeIsStep ? [{ id: "content", label: textContent.contentTab || "Contenu", icon: "notepad" }] : []), + { id: "advanced", label: textContent.advancedTab || "Avancé", icon: "settings" } + ]; + const parentMap = useMemo(() => getGoalTreeParentMap(evaluatedNodes), [evaluatedNodes]); + const reorder = useGroupedReorder({ + namespace: "goal-tree", + items: evaluatedNodes, + orientation: reorderOrientation, + getItemId: (node) => node.id, + getParentId: (node) => parentMap.get(node.id) || "", + onItemMove: (operation) => save({ ...data, nodes: moveItem(data.nodes, operation.sourceId, operation.targetId, operation.placement) }), + hierarchy: { enabled: true, stickyParents: true } + }); + + useEffect(() => { + const resetData = applyGoalTreeDueResets(data, Date.now()); + if (resetData !== data) save(resetData); + }); + + useEffect(() => { + if (!selectedNodeId && data.nodes[0]) setSelectedNodeId(data.nodes[0].id); + if (selectedNodeId && !data.nodes.some((node) => node.id === selectedNodeId)) setSelectedNodeId(data.nodes[0]?.id || ""); + }, [data.nodes, selectedNodeId]); + + useEffect(() => { + if (activeFormTab === "content" && selectedNodeIsStep) setActiveFormTab("base"); + }, [activeFormTab, selectedNodeIsStep]); + + useLayoutEffect(() => { + if (!storageReady || !evaluatedNodes.length) return undefined; + let firstFrameId = 0; + let secondFrameId = 0; + const timeoutIds = []; + const refreshConnectors = () => setConnectorRefreshTick((value) => value + 1); + firstFrameId = requestAnimationFrame(() => { + refreshConnectors(); + secondFrameId = requestAnimationFrame(refreshConnectors); + }); + timeoutIds.push(setTimeout(refreshConnectors, 80)); + timeoutIds.push(setTimeout(refreshConnectors, 240)); + window.addEventListener("load", refreshConnectors); + return () => { + cancelAnimationFrame(firstFrameId); + cancelAnimationFrame(secondFrameId); + timeoutIds.forEach(clearTimeout); + window.removeEventListener("load", refreshConnectors); + }; + }, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing, textExchangeOpen]); + + useLayoutEffect(() => { + const canvas = treeViewRef.current; + if (!canvas) return undefined; + let frameId = 0; + const measureCanvas = () => { + const rect = canvas.getBoundingClientRect(); + const nextSize = { + width: Math.ceil(Math.max(canvas.scrollWidth, canvas.offsetWidth, rect.width / viewScale)), + height: Math.ceil(Math.max(canvas.scrollHeight, canvas.offsetHeight, rect.height / viewScale)) + }; + setCanvasSize((current) => current.width === nextSize.width && current.height === nextSize.height ? current : nextSize); + }; + const scheduleMeasure = () => { + cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(measureCanvas); + }; + const resizeObserver = new ResizeObserver(scheduleMeasure); + resizeObserver.observe(canvas); + scheduleMeasure(); + return () => { + cancelAnimationFrame(frameId); + resizeObserver.disconnect(); + }; + }, [connectorTopologyKey, editing, textExchangeOpen, viewScale]); + + function save(nextData) { + context.setModuleData(toolboxId, moduleId, nextData, "goalTree"); + } + + function addNode(parentId = "") { + const parent = data.nodes.find((node) => node.id === parentId); + const node = { + id: context.uid("goal"), + parentId, + title: parent ? textContent.defaultChildTitle || "Nouvelle étape" : textContent.defaultRootTitle || "Nouvel objectif", + kind: "step", + icon: "cube", + color: "gold", + qtyTarget: 1, + qtyCurrent: 0, + formula: parent ? "base" : "", + resetType: "none", + resetTime: "00:00", + weeklyResetDay: 1, + items: [], + checkedAt: 0, + collapsed: false + }; + save({ ...data, nodes: [...data.nodes, node] }); + setSelectedNodeId(node.id); + } + + function editNode(nodeId) { + const node = data.nodes.find((item) => item.id === nodeId); + if (!node) return; + setSelectedNodeId(nodeId); + setActiveFormTab(node.kind === "step" ? "base" : "content"); + openEditor(); + } + + function addNodeContentItem(nodeId) { + const node = data.nodes.find((item) => item.id === nodeId); + if (!node || node.kind === "step") { + addNode(nodeId); + return; + } + setSelectedNodeId(nodeId); + setActiveFormTab("content"); + openEditor(); + addNodeItem(nodeId); + } + + function updateNode(nodeId, updater) { + save({ ...data, nodes: data.nodes.map((node) => node.id === nodeId ? updater(node) : node) }); + } + + function toggleBranch(nodeId) { + const target = data.nodes.find((node) => node.id === nodeId); + if (!target) return; + const nextCollapsed = !target.collapsed; + const keepOpenIds = target.collapsed ? getGoalTreeConvergenceKeepOpenIds(data.nodes, nodeId) : new Set(); + const linkedControlIds = getGoalTreeLinkedControlIds(data.nodes, nodeId); + const siblingIds = data.nodes + .filter((node) => node.parentId === target.parentId && node.id !== nodeId) + .map((node) => node.id); + const closedSiblingIds = new Set(siblingIds.flatMap((id) => [id, ...getGoalTreeDescendantIds(data.nodes, id)])); + save({ + ...data, + nodes: data.nodes.map((node) => { + if (linkedControlIds.has(node.id)) return { ...node, collapsed: nextCollapsed }; + if (!target.collapsed) return node; + if (keepOpenIds.has(node.id)) return { ...node, collapsed: false }; + return closedSiblingIds.has(node.id) ? { ...node, collapsed: true } : node; + }) + }); + } + + function deleteNode(nodeId) { + const deletedIds = new Set([nodeId, ...getGoalTreeDescendantIds(data.nodes, nodeId)]); + save({ ...data, nodes: data.nodes.filter((node) => !deletedIds.has(node.id)) }); + if (deletedIds.has(selectedNodeId)) setSelectedNodeId(data.nodes.find((node) => !deletedIds.has(node.id))?.id || ""); + } + + function setStepTarget(nodeId, value) { + const target = String(value || "").replaceAll(",", ".").trim(); + const numericTarget = Number(target); + updateNode(nodeId, (node) => { + if (target && Number.isFinite(numericTarget)) { + return { ...node, qtyTarget: Math.max(0, numericTarget), formula: "" }; + } + return { ...node, formula: target, qtyTarget: target ? node.qtyTarget : 1 }; + }); + } + + function setKind(nodeId, kind) { + updateNode(nodeId, (node) => ({ + ...node, + kind, + icon: kind === "step" ? node.icon || "cube" : KIND_ICONS[kind], + color: kind === "step" ? node.color || "gold" : node.color || "gold", + qtyTarget: kind === "step" ? node.qtyTarget : 1, + qtyCurrent: kind === "step" ? node.qtyCurrent : 0, + formula: kind === "step" ? node.formula : "", + items: kind === "step" ? [] : (node.items || []).map((item) => kind === "checklist" ? { + ...item, + formula: item.formula || (node.parentId ? "base" : "1"), + qtyTarget: item.qtyTarget || 1, + qtyCurrent: item.qtyCurrent || 0 + } : { id: item.id, label: item.label }) + })); + } + + function addNodeItem(nodeId) { + updateNode(nodeId, (node) => ({ + ...node, + items: [...(node.items || []), getDefaultGoalTreeItem(node.kind, context, textContent, node.parentId)] + })); + } + + function setStepLeafQuantity(nodeId, quantity) { + updateNode(nodeId, (node) => ({ + ...node, + qtyCurrent: quantity, + checkedAt: quantity > 0 ? Date.now() : 0 + })); + } + + function setStepChildrenCompletion(nodeId, checked) { + const childIds = new Set(getGoalTreeChildren(evaluatedNodes, nodeId).filter(isSingleValueGoalTreeNode).map((node) => node.id)); + if (!childIds.size) return; + save({ + ...data, + nodes: data.nodes.map((node) => { + if (!childIds.has(node.id)) return node; + const evaluatedNode = evaluatedNodeMap.get(node.id) || node; + if (node.kind === "checklist") { + const evaluatedItems = new Map((evaluatedNode.items || []).map((item) => [item.id, item])); + return { + ...node, + items: (node.items || []).map((item) => { + const evaluatedItem = evaluatedItems.get(item.id) || item; + const target = Math.max(1, evaluatedItem.qtyTarget || item.qtyTarget || 1); + return { ...item, qtyCurrent: checked ? target : 0 }; + }), + checkedAt: checked ? Date.now() : 0 + }; + } + const target = Math.max(1, evaluatedNode.computedQtyTarget || node.qtyTarget || 1); + return { + ...node, + qtyCurrent: checked ? target : 0, + checkedAt: checked ? Date.now() : 0 + }; + }) + }); + } + + function updateNodeItem(nodeId, itemId, updater) { + updateNode(nodeId, (node) => ({ + ...node, + items: (node.items || []).map((item) => item.id === itemId ? updater(item) : item) + })); + } + + function deleteNodeItem(nodeId, itemId) { + updateNode(nodeId, (node) => ({ + ...node, + items: (node.items || []).filter((item) => item.id !== itemId) + })); + } + + function importData(nextData) { + save(nextData); + setSelectedNodeId(nextData.nodes[0]?.id || ""); + } + + function toggleTreeOrientation() { + save({ ...data, orientation: verticalView ? "horizontal" : "vertical" }); + } + + function toggleLinkedParent(nodeId, parentId) { + updateNode(nodeId, (node) => { + const parentIds = new Set(getGoalTreeParentIds(node)); + if (parentIds.has(parentId)) parentIds.delete(parentId); + else parentIds.add(parentId); + if (node.parentId) parentIds.add(node.parentId); + return { ...node, parentIds: [...parentIds] }; + }); + } + + return ( +
+ {editing && ( +
+ {selectedNode ? ( +
+ + + {activeFormTab === "base" && ( +
+ + + {selectedNodeIsStep && ( + <> + updateNode(selectedNode.id, (node) => ({ ...node, icon }))} + /> +
+ {textContent.iconColorLabel || "Couleur de l'icône"} +
+ {GOAL_TREE_COLORS.map((color) => ( +
+
+ + )} + {!selectedNodeIsStep && ( +

{textContent.contentTabHint || "Le contenu se modifie dans l'onglet Contenu."}

+ )} +
+ )} + + {activeFormTab === "content" && !selectedNodeIsStep && ( +
+ addNodeItem(selectedNode.id)} + onUpdate={(itemId, updater) => updateNodeItem(selectedNode.id, itemId, updater)} + onDelete={(itemId) => deleteNodeItem(selectedNode.id, itemId)} + /> +
+ )} + + {activeFormTab === "content" && selectedNodeIsStep && ( +
+

{textContent.stepContentUnavailable || "Les étapes n'ont pas de contenu interne."}

+
+ )} + + {activeFormTab === "advanced" && ( +
+ + {selectedNode.resetType !== "none" && ( + + )} + {selectedNode.resetType === "weekly" && ( + + )} + {selectedNodeIsStep && ( + <> +
{textContent.quantitySectionTitle || "Quantité"}
+ +
{textContent.progressSectionTitle || "Progression"}
+