diff --git a/backend/server3.js b/backend/server3.js index 6509748..64e09f5 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -912,6 +912,246 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => } }); +/************************************************************************ + * MILESTONE IMPACTS ENDPOINTS + ************************************************************************/ +app.get('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => { + try { + // Example: GET /api/premium/milestone-impacts?milestone_id=12345 + const { milestone_id } = req.query; + if (!milestone_id) { + return res.status(400).json({ error: 'milestone_id is required.' }); + } + + // Verify the milestone belongs to this user + const milestoneRow = await db.get(` + SELECT user_id + FROM milestones + WHERE id = ? + `, [milestone_id]); + if (!milestoneRow || milestoneRow.user_id !== req.userId) { + return res.status(404).json({ error: 'Milestone not found or not owned by this user.' }); + } + + // Fetch all impacts for that milestone + const impacts = await db.all(` + SELECT + id, + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + created_at, + updated_at + FROM milestone_impacts + WHERE milestone_id = ? + ORDER BY created_at ASC + `, [milestone_id]); + + res.json({ impacts }); + } catch (err) { + console.error('Error fetching milestone impacts:', err); + res.status(500).json({ error: 'Failed to fetch milestone impacts.' }); + } +}); + +app.post('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => { + try { + const { + milestone_id, + impact_type, + direction = 'subtract', + amount = 0, + start_date = null, + end_date = null, + created_at, + updated_at + } = req.body; + + // Basic checks + if (!milestone_id || !impact_type) { + return res.status(400).json({ + error: 'milestone_id and impact_type are required.' + }); + } + + // Confirm user owns the milestone + const milestoneRow = await db.get(` + SELECT user_id + FROM milestones + WHERE id = ? + `, [milestone_id]); + if (!milestoneRow || milestoneRow.user_id !== req.userId) { + return res.status(403).json({ error: 'Milestone not found or not owned by this user.' }); + } + + // Generate UUID for this new Impact + const newUUID = uuidv4(); + const now = new Date().toISOString(); + const finalCreated = created_at || now; + const finalUpdated = updated_at || now; + + // Insert row WITH that UUID into the "id" column + await db.run(` + INSERT INTO milestone_impacts ( + id, + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + newUUID, + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + finalCreated, + finalUpdated + ]); + + // Fetch & return the inserted row + const insertedRow = await db.get(` + SELECT + id, + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + created_at, + updated_at + FROM milestone_impacts + WHERE id = ? + `, [newUUID]); + + return res.status(201).json(insertedRow); + } catch (err) { + console.error('Error creating milestone impact:', err); + return res.status(500).json({ error: 'Failed to create milestone impact.' }); + } +}); + +/************************************************************************ + * UPDATE an existing milestone impact (PUT) + ************************************************************************/ +app.put('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => { + try { + const { impactId } = req.params; + const { + milestone_id, + impact_type, + direction = 'subtract', + amount = 0, + start_date = null, + end_date = null + } = req.body; + + // 1) Check this impact belongs to user + const existing = await db.get(` + SELECT mi.id, m.user_id + FROM milestone_impacts mi + JOIN milestones m ON mi.milestone_id = m.id + WHERE mi.id = ? + `, [impactId]); + if (!existing || existing.user_id !== req.userId) { + return res.status(404).json({ error: 'Impact not found or not owned by user.' }); + } + + const now = new Date().toISOString(); + + // 2) Update + await db.run(` + UPDATE milestone_impacts + SET + milestone_id = ?, + impact_type = ?, + direction = ?, + amount = ?, + start_date = ?, + end_date = ?, + updated_at = ? + WHERE id = ? + `, [ + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + now, + impactId + ]); + + // 3) Return updated + const updatedRow = await db.get(` + SELECT + id, + milestone_id, + impact_type, + direction, + amount, + start_date, + end_date, + created_at, + updated_at + FROM milestone_impacts + WHERE id = ? + `, [impactId]); + + res.json(updatedRow); + } catch (err) { + console.error('Error updating milestone impact:', err); + res.status(500).json({ error: 'Failed to update milestone impact.' }); + } +}); + +/************************************************************************ + * DELETE an existing milestone impact + ************************************************************************/ +app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => { + try { + const { impactId } = req.params; + + // 1) check ownership + const existing = await db.get(` + SELECT mi.id, m.user_id + FROM milestone_impacts mi + JOIN milestones m ON mi.milestone_id = m.id + WHERE mi.id = ? + `, [impactId]); + + if (!existing || existing.user_id !== req.userId) { + return res.status(404).json({ error: 'Impact not found or not owned by user.' }); + } + + // 2) Delete + await db.run(` + DELETE FROM milestone_impacts + WHERE id = ? + `, [impactId]); + + res.json({ message: 'Impact deleted successfully.' }); + } catch (err) { + console.error('Error deleting milestone impact:', err); + res.status(500).json({ error: 'Failed to delete milestone impact.' }); + } +}); + +app.use((req, res) => { + console.warn(`No route matched for ${req.method} ${req.originalUrl}`); + res.status(404).json({ error: 'Not found' }); +}); app.listen(PORT, () => { console.log(`Premium server running on http://localhost:${PORT}`); diff --git a/src/components/MilestoneAddModal.js b/src/components/MilestoneAddModal.js new file mode 100644 index 0000000..07b580b --- /dev/null +++ b/src/components/MilestoneAddModal.js @@ -0,0 +1,265 @@ +// src/components/MilestoneAddModal.js +import React, { useState, useEffect } from 'react'; +import authFetch from '../utils/authFetch.js'; + +const MilestoneAddModal = ({ + show, + onClose, + defaultScenarioId, + scenarioId, // which scenario this milestone applies to + editMilestone, // if editing an existing milestone, pass its data + apiURL +}) => { + // Basic milestone fields + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + // We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month } + const [impacts, setImpacts] = useState([]); + + // On open, if editing, fill in existing fields + useEffect(() => { + if (!show) return; // if modal is hidden, do nothing + + if (editMilestone) { + setTitle(editMilestone.title || ''); + setDescription(editMilestone.description || ''); + // If editing, you might fetch existing impacts from the server or they could be passed in + if (editMilestone.impacts) { + setImpacts(editMilestone.impacts); + } else { + // fetch from backend if needed + // e.g. GET /api/premium/milestones/:id/impacts + } + } else { + // Creating a new milestone + setTitle(''); + setDescription(''); + setImpacts([]); + } + }, [show, editMilestone]); + + // Handler: add a new blank impact + const handleAddImpact = () => { + setImpacts((prev) => [ + ...prev, + { + impact_type: 'ONE_TIME', + direction: 'subtract', + amount: 0, + start_month: 0, + end_month: null + } + ]); + }; + + // Handler: update a single impact in the array + const handleImpactChange = (index, field, value) => { + setImpacts((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], [field]: value }; + return updated; + }); + }; + + // Handler: remove an impact row + const handleRemoveImpact = (index) => { + setImpacts((prev) => prev.filter((_, i) => i !== index)); + }; + + // Handler: Save everything to the server + const handleSave = async () => { + try { + let milestoneId; + if (editMilestone) { + // 1) Update existing milestone + milestoneId = editMilestone.id; + await authFetch(`${apiURL}/premium/milestones/${milestoneId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description, + scenario_id: scenarioId, + // Possibly other fields + }) + }); + // Then handle impacts below... + } else { + // 1) Create new milestone + const res = await authFetch(`${apiURL}/premium/milestones`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description, + scenario_id: scenarioId + }) + }); + if (!res.ok) throw new Error('Failed to create milestone'); + const created = await res.json(); + milestoneId = created.id; // assuming the response returns { id: newMilestoneId } + } + + // 2) For the impacts, we can do a batch approach or individual calls + // For simplicity, let's do multiple POST calls + for (const impact of impacts) { + // If editing, you might do a PUT if the impact already has an id + await authFetch(`${apiURL}/premium/milestone-impacts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + milestone_id: milestoneId, + impact_type: impact.impact_type, + direction: impact.direction, + amount: parseFloat(impact.amount) || 0, + start_month: parseInt(impact.start_month, 10) || 0, + end_month: impact.end_month !== null + ? parseInt(impact.end_month, 10) + : null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }) + }); + } + + // Done, close modal + onClose(); + } catch (err) { + console.error('Failed to save milestone + impacts:', err); + // Show some UI error if needed + } + }; + + if (!show) return null; + + return ( +
+
+

+ {editMilestone ? 'Edit Milestone' : 'Add Milestone'} +

+ +
+ + setTitle(e.target.value)} + className="border w-full px-2 py-1" + /> +
+ +
+ +