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() { +
+ Transférez les coquillages réels reçus ou utilisés, ou modifiez directement les soldes de Hailey, Violette et Andreas. +
+