259 lines
9.0 KiB
JavaScript
259 lines
9.0 KiB
JavaScript
// src/components/MilestoneAddModal.js
|
||
import React, { useState, useEffect } from 'react';
|
||
import authFetch from '../utils/authFetch.js';
|
||
|
||
/* ─────────────────────────────────────────────────────────
|
||
CONSTANTS
|
||
───────────────────────────────────────────────────────── */
|
||
const IMPACT_TYPES = ['salary', 'cost', 'tuition', 'note'];
|
||
const FREQ_OPTIONS = ['ONE_TIME', 'MONTHLY'];
|
||
|
||
export default function MilestoneAddModal({
|
||
show,
|
||
onClose,
|
||
scenarioId, // active scenario UUID
|
||
editMilestone = null // pass full row when editing
|
||
}) {
|
||
/* ────────────── state ────────────── */
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [impacts, setImpacts] = useState([]);
|
||
|
||
/* ────────────── init / reset ────────────── */
|
||
useEffect(() => {
|
||
if (!show) return;
|
||
|
||
if (editMilestone) {
|
||
setTitle(editMilestone.title || '');
|
||
setDescription(editMilestone.description || '');
|
||
setImpacts(editMilestone.impacts || []);
|
||
} else {
|
||
setTitle(''); setDescription(''); setImpacts([]);
|
||
}
|
||
}, [show, editMilestone]);
|
||
|
||
/* ────────────── helpers ────────────── */
|
||
const addImpactRow = () =>
|
||
setImpacts(prev => [
|
||
...prev,
|
||
{
|
||
impact_type : 'cost',
|
||
frequency : 'ONE_TIME',
|
||
direction : 'subtract',
|
||
amount : 0,
|
||
start_date : '', // ISO yyyy‑mm‑dd
|
||
end_date : '' // blank ⇒ indefinite
|
||
}
|
||
]);
|
||
|
||
const updateImpact = (idx, field, value) =>
|
||
setImpacts(prev => {
|
||
const copy = [...prev];
|
||
copy[idx] = { ...copy[idx], [field]: value };
|
||
return copy;
|
||
});
|
||
|
||
const removeImpact = idx =>
|
||
setImpacts(prev => prev.filter((_, i) => i !== idx));
|
||
|
||
/* ────────────── save ────────────── */
|
||
async function handleSave() {
|
||
try {
|
||
/* 1️⃣ create OR update the milestone row */
|
||
let milestoneId = editMilestone?.id;
|
||
if (milestoneId) {
|
||
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
||
method : 'PUT',
|
||
headers: { 'Content-Type':'application/json' },
|
||
body : JSON.stringify({ title, description })
|
||
});
|
||
} else {
|
||
const res = await authFetch('api/premium/milestones', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type':'application/json' },
|
||
body : JSON.stringify({
|
||
title,
|
||
description,
|
||
career_profile_id: scenarioId
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error('Milestone create failed');
|
||
const json = await res.json();
|
||
milestoneId = json.id ?? json[0]?.id; // array OR obj
|
||
}
|
||
|
||
/* 2️⃣ upsert each impact (one call per row) */
|
||
for (const imp of impacts) {
|
||
const body = {
|
||
milestone_id : milestoneId,
|
||
impact_type : imp.impact_type,
|
||
frequency : imp.frequency, // ONE_TIME / MONTHLY
|
||
direction : imp.direction,
|
||
amount : parseFloat(imp.amount) || 0,
|
||
start_date : imp.start_date || null,
|
||
end_date : imp.frequency === 'MONTHLY' && imp.end_date
|
||
? imp.end_date
|
||
: null
|
||
};
|
||
await authFetch('api/premium/milestone-impacts', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type':'application/json' },
|
||
body : JSON.stringify(body)
|
||
});
|
||
}
|
||
|
||
onClose(true); // ← parent will refetch
|
||
} catch (err) {
|
||
console.error('Save failed:', err);
|
||
alert('Sorry, something went wrong – please try again.');
|
||
}
|
||
}
|
||
|
||
/* ────────────── UI ────────────── */
|
||
if (!show) return null;
|
||
return (
|
||
<div className="modal-backdrop">
|
||
<div className="modal-container w-full max-w-lg">
|
||
<h2 className="text-xl font-bold mb-2">
|
||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||
</h2>
|
||
|
||
{/* basic fields */}
|
||
<label className="block font-semibold mt-2">Title</label>
|
||
<input
|
||
value={title}
|
||
onChange={e => setTitle(e.target.value)}
|
||
className="border w-full px-2 py-1"
|
||
/>
|
||
|
||
<label className="block font-semibold mt-4">Description</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={e => setDescription(e.target.value)}
|
||
rows={3}
|
||
className="border w-full px-2 py-1"
|
||
/>
|
||
|
||
{/* impacts */}
|
||
<h3 className="text-lg font-semibold mt-6">Financial Impacts</h3>
|
||
|
||
{impacts.map((imp, i) => (
|
||
<div key={i} className="border rounded p-3 mt-4 space-y-2">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-medium">Impact #{i + 1}</span>
|
||
<button
|
||
className="text-red-600 text-sm"
|
||
onClick={() => removeImpact(i)}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
|
||
{/* type */}
|
||
<div>
|
||
<label className="block text-sm font-semibold">Type</label>
|
||
<select
|
||
value={imp.impact_type}
|
||
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
>
|
||
{IMPACT_TYPES.map(t => (
|
||
<option key={t} value={t}>
|
||
{t === 'salary' ? 'Salary change'
|
||
: t === 'cost' ? 'Cost / expense'
|
||
: t.charAt(0).toUpperCase() + t.slice(1)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* frequency */}
|
||
<div>
|
||
<label className="block text-sm font-semibold">Frequency</label>
|
||
<select
|
||
value={imp.frequency}
|
||
onChange={e => updateImpact(i, 'frequency', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
>
|
||
<option value="ONE_TIME">One‑time</option>
|
||
<option value="MONTHLY">Monthly (recurring)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* direction */}
|
||
<div>
|
||
<label className="block text-sm font-semibold">Direction</label>
|
||
<select
|
||
value={imp.direction}
|
||
onChange={e => updateImpact(i, 'direction', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
>
|
||
<option value="add">Add (income)</option>
|
||
<option value="subtract">Subtract (expense)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* amount */}
|
||
<div>
|
||
<label className="block text-sm font-semibold">Amount ($)</label>
|
||
<input
|
||
type="number"
|
||
value={imp.amount}
|
||
onChange={e => updateImpact(i, 'amount', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* dates */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold">Start date</label>
|
||
<input
|
||
type="date"
|
||
value={imp.start_date}
|
||
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
/>
|
||
</div>
|
||
|
||
{imp.frequency === 'MONTHLY' && (
|
||
<div>
|
||
<label className="block text-sm font-semibold">
|
||
End date (optional)
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={imp.end_date || ''}
|
||
onChange={e => updateImpact(i, 'end_date', e.target.value)}
|
||
className="border px-2 py-1 w-full"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<button
|
||
onClick={addImpactRow}
|
||
className="bg-gray-200 px-4 py-1 rounded mt-4"
|
||
>
|
||
+ Add impact
|
||
</button>
|
||
|
||
{/* actions */}
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button onClick={() => onClose(false)} className="px-4 py-2">
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
className="bg-blue-600 text-white px-5 py-2 rounded"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|