From 9c48217c366fd0e548817b235629197aa2358e0f Mon Sep 17 00:00:00 2001 From: streaper2 Date: Wed, 11 Nov 2026 21:53:45 +0100 Subject: [PATCH] feat: gestion directe des banques enfants et attribution des coquillages par activite --- back/src/server.js | 184 ++++++++++++ front/src/App.jsx | 726 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 849 insertions(+), 61 deletions(-) diff --git a/back/src/server.js b/back/src/server.js index 6949378..e41ee41 100644 --- a/back/src/server.js +++ b/back/src/server.js @@ -290,6 +290,71 @@ app.delete('/api/activities/:id', (req, res) => { res.json({ message: 'Activité supprimée.' }); }); +// 6b. POST /api/activities/:id/direct-contribute (Attribution directe de coquillages par les parents pour un enfant) +app.post('/api/activities/:id/direct-contribute', (req, res) => { + const activityId = Number(req.params.id); + const activity = db.prepare('SELECT * FROM activities WHERE id = ?').get(activityId); + if (!activity) return res.status(404).json({ error: 'Activité introuvable.' }); + + const { child_id, shells, source_type = 'PHYSICAL' } = req.body; + const child = db.prepare('SELECT * FROM children WHERE id = ?').get(Number(child_id)); + if (!child) return res.status(404).json({ error: 'Enfant introuvable.' }); + + const count = Number(shells); + if (!count || count <= 0) { + return res.status(400).json({ error: 'Veuillez saisir un nombre de coquillages supérieur à 0.' }); + } + + const isVirtual = source_type === 'VIRTUAL'; + if (isVirtual && child.available_shells < count) { + return res.status(400).json({ + error: `Solde virtuel insuffisant : ${child.name} n'a que ${child.available_shells} coquillages disponibles.` + }); + } + + const now = new Date().toISOString(); + + // Déduire si c'est virtuel + if (isVirtual) { + db.prepare('UPDATE children SET available_shells = available_shells - ? WHERE id = ?').run(count, child.id); + } + + // Créditer l'activité + const newTotal = activity.current_shells + count; + const newStatus = newTotal >= activity.cost_shells ? 'UNLOCKED' : activity.status; + db.prepare('UPDATE activities SET current_shells = ?, status = ? WHERE id = ?').run(newTotal, newStatus, activity.id); + + // Enregistrer la contribution + db.prepare(` + INSERT INTO activity_contributions (activity_id, child_id, shells, created_at) + VALUES (?, ?, ?, ?) + `).run(activity.id, child.id, count, now); + + // Résoudre d'éventuelles requêtes Alexa PENDING pour cet enfant sur cette activité afin de déverrouiller son bouton + db.prepare(` + UPDATE pending_requests + SET status = 'APPROVED', resolved_at = ? + WHERE activity_id = ? AND child_id = ? AND status = 'PENDING' + `).run(now, activity.id, child.id); + + // Enregistrer la transaction + const sourceLabel = isVirtual ? 'déduits du compte virtuel' : 'coquillages physiques remis'; + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'CONTRIBUTION', ?, 0, ?, ?) + `).run( + child.id, + isVirtual ? -count : 0, + `${child.name} a mis ${count} 🐚 (${sourceLabel}) pour "${activity.title}" (${newTotal}/${activity.cost_shells})`, + now + ); + + res.json({ + message: `${count} 🐚 attribués avec succès à "${activity.title}" pour ${child.name} !`, + activity: getEnrichedActivities().find((a) => a.id === activityId) + }); +}); + // 7. POST /api/requests app.post('/api/requests', (req, res) => { const { @@ -701,6 +766,125 @@ app.post('/api/children/:id/adjust', (req, res) => { res.json({ message: 'Compte mis à jour.', child: getEnrichedChildren().find((c) => c.id === id) }); }); +// 13b. PUT /api/children/:id (Édition directe des soldes de l'enfant par les parents) +app.put('/api/children/:id', (req, res) => { + const id = Number(req.params.id); + const child = db.prepare('SELECT * FROM children WHERE id = ?').get(id); + if (!child) return res.status(404).json({ error: 'Enfant introuvable.' }); + + const { available_shells, locked_shells, euros_balance } = req.body; + const newAvailable = available_shells !== undefined ? Math.max(0, Number(available_shells)) : child.available_shells; + const newLocked = locked_shells !== undefined ? Math.max(0, Number(locked_shells)) : child.locked_shells; + const newEuros = euros_balance !== undefined ? Math.max(0, Number(euros_balance)) : child.euros_balance; + const now = new Date().toISOString(); + + db.prepare(` + UPDATE children + SET available_shells = ?, locked_shells = ?, euros_balance = ? + WHERE id = ? + `).run(newAvailable, newLocked, newEuros, id); + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'ADMIN_EDIT', ?, ?, ?, ?) + `).run( + id, + newAvailable - child.available_shells, + newEuros - child.euros_balance, + `Mise à jour des soldes de ${child.name} : ${newAvailable} 🐚 dispo, ${newLocked} 🔒 bloqués, ${newEuros.toFixed(2)} €`, + now + ); + + res.json({ + message: `Soldes de ${child.name} enregistrés avec succès !`, + child: getEnrichedChildren().find((c) => c.id === id) + }); +}); + +// 13c. POST /api/children/:id/transfer (Transfert réel <-> virtuel, blocage/déblocage rapide) +app.post('/api/children/:id/transfer', (req, res) => { + const id = Number(req.params.id); + const child = db.prepare('SELECT * FROM children WHERE id = ?').get(id); + if (!child) return res.status(404).json({ error: 'Enfant introuvable.' }); + + const { type, shells, reason = '' } = req.body; + const count = Number(shells); + if (!count || count <= 0) { + return res.status(400).json({ error: 'Le nombre de coquillages doit être supérieur à 0.' }); + } + + const now = new Date().toISOString(); + + switch (type) { + case 'REAL_TO_AVAILABLE': { + // Coquillages physiques reçus -> crédités sur le compte disponible + db.prepare('UPDATE children SET available_shells = available_shells + ? WHERE id = ?').run(count, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'TRANSFER_REAL_AVAILABLE', ?, 0, ?, ?) + `).run(id, count, `Dépôt réel : +${count} 🐚 physiques ajoutés au compte disponible de ${child.name} (${reason || 'Remis en main propre'})`, now); + break; + } + + case 'REAL_TO_LOCKED': { + // Coquillages physiques reçus -> directement bloqués sur le livret (+intérêts) + db.prepare('UPDATE children SET locked_shells = locked_shells + ? WHERE id = ?').run(count, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'TRANSFER_REAL_LOCKED', ?, 0, ?, ?) + `).run(id, count, `Dépôt réel bloqué : +${count} 🔒 bloqués pour ${child.name} (${reason || 'Livret épargne'})`, now); + break; + } + + case 'AVAILABLE_TO_REAL': { + // L'enfant reprend/utilise des coquillages réels -> débités du compte virtuel disponible + if (child.available_shells < count) { + return res.status(400).json({ error: `Solde insuffisant : ${child.name} n'a que ${child.available_shells} coquillages disponibles.` }); + } + db.prepare('UPDATE children SET available_shells = available_shells - ? WHERE id = ?').run(count, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'TRANSFER_AVAILABLE_REAL', ?, 0, ?, ?) + `).run(id, -count, `Retrait réel : -${count} 🐚 utilisés dans la vraie vie par ${child.name} (${reason || 'Utilisation réelle'})`, now); + break; + } + + case 'LOCK': { + // Transfert interne : disponible -> bloqué + if (child.available_shells < count) { + return res.status(400).json({ error: `Solde disponible insuffisant (${child.available_shells} 🐚 dispo).` }); + } + db.prepare('UPDATE children SET available_shells = available_shells - ?, locked_shells = locked_shells + ? WHERE id = ?').run(count, count, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'LOCK_VIRTUAL', 0, 0, ?, ?) + `).run(id, `${child.name} a bloqué ${count} 🐚 vers son livret épargne`, now); + break; + } + + case 'UNLOCK': { + // Transfert interne : bloqué -> disponible + if (child.locked_shells < count) { + return res.status(400).json({ error: `Solde bloqué insuffisant (${child.locked_shells} 🔒 bloqués).` }); + } + db.prepare('UPDATE children SET available_shells = available_shells + ?, locked_shells = locked_shells - ? WHERE id = ?').run(count, count, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'UNLOCK_VIRTUAL', 0, 0, ?, ?) + `).run(id, `${count} 🐚 bloqués reversés sur le compte disponible de ${child.name}`, now); + break; + } + + default: + return res.status(400).json({ error: `Type de transfert inconnu: ${type}` }); + } + + res.json({ + message: `Opération enregistrée pour ${child.name} !`, + child: getEnrichedChildren().find((c) => c.id === id) + }); +}); + // 14. POST /api/alexa/voice-intent (Avec support Personalisation Voice ID, cartes illustrées & APL Echo Show) app.post('/api/alexa/voice-intent', (req, res) => { const { intent, slots = {}, personId = null, personGivenName = null } = req.body; diff --git a/front/src/App.jsx b/front/src/App.jsx index e579088..0e502c6 100644 --- a/front/src/App.jsx +++ b/front/src/App.jsx @@ -104,6 +104,28 @@ export default function App() { const [unlockAfterInterest, setUnlockAfterInterest] = useState(false); const [approvalShells, setApprovalShells] = useState({}); + // Modal for Parents Direct Bank Management & Real/Virtual Transfer + const [editBankModal, setEditBankModal] = useState({ + open: false, + child: null, + activeMode: 'transfer', // 'transfer' | 'manual' + transferType: 'REAL_TO_AVAILABLE', + shells: 5, + reason: '', + manualAvailable: 0, + manualLocked: 0, + manualEuros: 0 + }); + + // Modal for Parents Direct Contribution to an Activity + const [directContributeModal, setDirectContributeModal] = useState({ + open: false, + activity: null, + childId: 1, + shells: 5, + sourceType: 'PHYSICAL' // 'PHYSICAL' | 'VIRTUAL' + }); + // Alexa simulator state (Voice + Echo Show visual card) const [alexaResponse, setAlexaResponse] = useState( 'Cliquez sur une commande vocale ci-dessous pour entendre Alexa répondre et voir l\'écran Echo Show !' @@ -249,6 +271,81 @@ export default function App() { } } + // Direct Parent attribution of shells to an activity + async function handleDirectContribute(e) { + e.preventDefault(); + if (!directContributeModal.activity) return; + try { + const res = await fetch(`${API_URL}/activities/${directContributeModal.activity.id}/direct-contribute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + child_id: Number(directContributeModal.childId), + shells: Number(directContributeModal.shells), + source_type: directContributeModal.sourceType + }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur lors de l\'attribution', true); + return; + } + showToast(json.message); + setDirectContributeModal({ ...directContributeModal, open: false }); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Direct Parent edit or transfer of child's bank + async function handleSaveBankEdit(e) { + e.preventDefault(); + const { child, activeMode, transferType, shells, reason, manualAvailable, manualLocked, manualEuros } = editBankModal; + if (!child) return; + + try { + if (activeMode === 'manual') { + const res = await fetch(`${API_URL}/children/${child.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + available_shells: Number(manualAvailable), + locked_shells: Number(manualLocked), + euros_balance: Number(manualEuros) + }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur lors de la modification', true); + return; + } + showToast(json.message); + } else { + const res = await fetch(`${API_URL}/children/${child.id}/transfer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: transferType, + shells: Number(shells), + reason + }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur lors du transfert', true); + return; + } + showToast(json.message); + } + + setEditBankModal({ ...editBankModal, open: false }); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + // Apply monthly interest engine async function handleApplyMonthlyInterest() { try { @@ -547,6 +644,25 @@ export default function App() { +
@@ -752,39 +868,47 @@ export default function App() {
- {/* Contribute Buttons for each Child (Locked if pending!) */} -
- Ajouter des 🐚 : -
+ {/* Attribution Directe des Coquillages (Parents & Enfants) */} +
+ + +
{children.map((child) => { const isLocked = Array.isArray(act.locked_child_ids) && act.locked_child_ids.includes(child.id); - if (isLocked) { - return ( - - 🔒 {child.name} (En attente) - - ); - } return ( ); })} @@ -1264,56 +1388,127 @@ export default function App() {
- {/* 4. Ajustement Rapide des Comptes & Journal */} + {/* 4. Gestion Complète & Ajustement des Comptes (Parents) */}
-

⚙️ Ajustement Manuel des Comptes (Parents)

-
+
+
+

+ Gestion & Édition de la Banque des Enfants +

+

+ Transférez les coquillages réels reçus ou utilisés, ou modifiez directement les soldes de Hailey, Violette et Andreas. +

+
+
+ +
{children.map((c) => ( -
+
- - {c.avatar} {c.name} - - - {c.available_shells} dispo • {c.locked_shells} bloqués • {c.euros_balance.toFixed(2)}€ - +
+
+ {c.avatar} +
+
+

{c.name}

+ + Total : {c.total_shells} 🐚 + +
+
-
- - - - {c.locked_shells > 0 && ( +
+
+ Dispo + {c.available_shells} 🐚 +
+
+ Bloqué + {c.locked_shells} 🔒 +
+
+ Tirelire + {c.euros_balance.toFixed(2)} € +
+
+ +
+
- )} - {c.euros_balance > 0 && ( - )} +
+ +
+ + + + {c.locked_shells > 0 && ( + + )} + {c.euros_balance > 0 && ( + + )} +
))} @@ -1757,6 +1952,415 @@ export default function App() {
)} + + {/* Modal 1 : Édition & Transfert Banque Enfant (Parents) */} + {editBankModal.open && editBankModal.child && ( +
+
+
+
+
+ {editBankModal.child.avatar} +
+
+

+ Gérer la banque de {editBankModal.child.name} +

+ + Solde actuel : {editBankModal.child.available_shells} 🐚 dispo • {editBankModal.child.locked_shells} 🔒 bloqués • {editBankModal.child.euros_balance.toFixed(2)} € + +
+
+ +
+ + {/* Mode Selector Tabs: Transfert Réel/Virtuel vs Édition Manuelle Directe */} +
+ + +
+ +
+ {editBankModal.activeMode === 'transfer' ? ( + <> +
+ +
+ {[ + { + id: 'REAL_TO_AVAILABLE', + icon: '📥', + title: 'Dépôt Réel → Virtuel (+🐚 dispo)', + desc: 'L\'enfant donne des vrais coquillages pour les mettre sur son compte disponible.' + }, + { + id: 'REAL_TO_LOCKED', + icon: '🔒', + title: 'Dépôt Réel → Livret Bloqué (+🔒)', + desc: 'L\'enfant donne des vrais coquillages à placer directement à +20% d\'intérêts.' + }, + { + id: 'AVAILABLE_TO_REAL', + icon: '📤', + title: 'Utilisation Réelle (Virtuel → Réel -🐚)', + desc: 'L\'enfant utilise des coquillages dans la vraie vie (déduits de son solde dispo).' + }, + { + id: 'LOCK', + icon: '📈', + title: 'Bloquer : Disponible → Livret', + desc: 'Transférer des coquillages du compte disponible vers le livret épargne.' + }, + { + id: 'UNLOCK', + icon: '🔓', + title: 'Débloquer : Livret → Disponible', + desc: 'Rapatrier des coquillages du livret vers le compte disponible.' + } + ].map((op) => ( + + ))} +
+
+ +
+ +
+ setEditBankModal({ ...editBankModal, shells: e.target.value })} + className="w-24 bg-slate-950 border border-white/15 rounded-xl px-3.5 py-2 text-center text-lg font-black text-amber-300" + /> +
+ {[1, 2, 5, 10, 20].map((n) => ( + + ))} +
+
+
+ +
+ + setEditBankModal({ ...editBankModal, reason: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3.5 py-2 text-xs" + /> +
+ + ) : ( +
+

+ ✏️ Édition directe des soldes : saisissez ici le montant exact que vous souhaitez attribuer à {editBankModal.child.name}. +

+ +
+
+ + setEditBankModal({ ...editBankModal, manualAvailable: e.target.value })} + className="w-full bg-slate-900 border border-white/15 rounded-xl px-3 py-2 text-center font-black text-amber-300 text-lg" + /> +
+ +
+ + setEditBankModal({ ...editBankModal, manualLocked: e.target.value })} + className="w-full bg-slate-900 border border-white/15 rounded-xl px-3 py-2 text-center font-black text-cyan-300 text-lg" + /> +
+ +
+ + setEditBankModal({ ...editBankModal, manualEuros: e.target.value })} + className="w-full bg-slate-900 border border-white/15 rounded-xl px-3 py-2 text-center font-black text-emerald-300 text-lg" + /> +
+
+
+ )} + +
+ + +
+
+
+
+ )} + + {/* Modal 2 : Attribution Directe de Coquillages à une Activité (Parents) */} + {directContributeModal.open && directContributeModal.activity && ( +
+
+
+
+ + Attribution de Coquillages (Parents) + +

+ {directContributeModal.activity.emoji} {directContributeModal.activity.title} +

+ + Pot actuel : {directContributeModal.activity.current_shells} / {directContributeModal.activity.cost_shells} 🐚 (Il manque {directContributeModal.activity.remaining_shells} 🐚) + +
+ +
+ +
+ {/* Étape 1 : Quel enfant participe */} +
+ +
+ {children.map((c) => { + const isSelected = Number(directContributeModal.childId) === c.id; + const alreadyPut = directContributeModal.activity.contributions?.find((cb) => cb.child_id === c.id)?.total_shells || 0; + return ( + + ); + })} +
+
+ + {/* Étape 2 : Combien de coquillages */} +
+ +
+ setDirectContributeModal({ ...directContributeModal, shells: e.target.value })} + className="w-28 bg-slate-950 border border-white/15 rounded-xl px-4 py-2.5 text-center text-xl font-black text-amber-300" + /> +
+ {[1, 2, 5, 10].map((n) => ( + + ))} + {directContributeModal.activity.remaining_shells > 0 && ( + + )} +
+
+
+ + {/* Étape 3 : Mode de prélèvement */} +
+ +
+ + + +
+
+ +
+ + +
+
+
+
+ )}
); }