Fixed some things, broke some others.
This commit is contained in:
parent
47819493cc
commit
1868c4348a
@ -1,29 +1,15 @@
|
||||
// backend/config/mysqlPool.js
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// load .env.<env>
|
||||
dotenv.config({ path: path.resolve(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`) });
|
||||
|
||||
/** decide: socket vs TCP */
|
||||
let poolConfig;
|
||||
if (process.env.DB_SOCKET) {
|
||||
poolConfig = { socketPath: process.env.DB_SOCKET };
|
||||
} else {
|
||||
poolConfig = {
|
||||
const pool = mysql.createPool({
|
||||
host : process.env.DB_HOST || '127.0.0.1',
|
||||
port : process.env.DB_PORT || 3306,
|
||||
user : process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
};
|
||||
}
|
||||
poolConfig.database = process.env.DB_NAME || 'user_profile_db';
|
||||
poolConfig.waitForConnections = true;
|
||||
poolConfig.connectionLimit = 10;
|
||||
password : process.env.DB_PASSWORD || '',
|
||||
database : process.env.DB_NAME || 'user_profile_db',
|
||||
waitForConnections : true,
|
||||
connectionLimit : 10,
|
||||
...(process.env.DB_SOCKET ? { socketPath: process.env.DB_SOCKET } : {})
|
||||
});
|
||||
|
||||
export default mysql.createPool(poolConfig);
|
||||
export default pool;
|
@ -1,35 +1,31 @@
|
||||
//
|
||||
// server3.js - MySQL Version
|
||||
//
|
||||
// ─── server3.js ────────────────────────────────────────────────────────────
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const env = (process.env.NODE_ENV || 'development').trim();
|
||||
const envPath = path.resolve(__dirname, '..', `.env.${env}`);
|
||||
dotenv.config({ path: envPath }); // ✅ envs are now ready
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import multer from 'multer';
|
||||
import fetch from "node-fetch";
|
||||
import fetch from 'node-fetch';
|
||||
import mammoth from 'mammoth';
|
||||
import { fileURLToPath } from 'url';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import pkg from 'pdfjs-dist';
|
||||
import db from './config/mysqlPool.js'; // Adjust path as necessary
|
||||
import db from './config/mysqlPool.js';
|
||||
import './jobs/reminderCron.js';
|
||||
import OpenAI from 'openai';
|
||||
import Fuse from 'fuse.js';
|
||||
import { createReminder } from './utils/smsService.js';
|
||||
|
||||
const pool = db;
|
||||
|
||||
// Basic file init
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
||||
const env = process.env.NODE_ENV?.trim() || 'development';
|
||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||
dotenv.config({ path: envPath }); // Load .env file
|
||||
|
||||
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||||
|
||||
@ -49,6 +45,7 @@ function internalFetch(req, url, opts = {}) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 2) Basic middlewares
|
||||
app.use(helmet());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
@ -73,6 +70,8 @@ const authenticatePremiumUser = (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pool = db;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
CAREER PROFILE ENDPOINTS
|
||||
------------------------------------------------------------------ */
|
||||
@ -513,17 +512,26 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
|
||||
} = req.body;
|
||||
|
||||
let existingTitles = [];
|
||||
let miniGrid = "-none-"; // slim grid
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
|
||||
|
||||
`SELECT id, DATE_FORMAT(date,'%Y-%m-%d') AS d, title
|
||||
FROM milestones
|
||||
WHERE user_id = ? AND career_profile_id = ?`,
|
||||
[req.id, scenarioRow.id]
|
||||
);
|
||||
|
||||
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
|
||||
} catch (e) {
|
||||
console.error("Could not fetch existing milestones =>", e);
|
||||
|
||||
if (rows.length) {
|
||||
miniGrid = rows.map(r => `${r.id}|${r.title.trim()}|${r.d}`).join("\n");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not fetch existing milestones ⇒", e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// 1. Helper Functions
|
||||
// ------------------------------------------------
|
||||
@ -824,34 +832,70 @@ ${econText}
|
||||
// 5. Construct System-Level Prompts
|
||||
// ------------------------------------------------
|
||||
const systemPromptIntro = `
|
||||
You are Jess, a professional career coach working inside AptivaAI.
|
||||
+You are **Jess**, a professional career coach inside AptivaAI.
|
||||
+Your mandate: turn the user’s real data into clear, empathetic, *actionable* guidance.
|
||||
+
|
||||
+────────────────────────────────────────────────────────
|
||||
+What Jess can do directly in Aptiva
|
||||
+────────────────────────────────────────────────────────
|
||||
+• **Create** new milestones (with tasks & financial impacts)
|
||||
+• **Update** any field on an existing milestone
|
||||
+• **Delete** milestones that are no longer relevant
|
||||
+• **Add / edit / remove** tasks inside a milestone
|
||||
+• Run salary benchmarks, AI-risk checks, and financial projections
|
||||
+
|
||||
+────────────────────────────────────────────────────────
|
||||
+Mission & Tone
|
||||
+────────────────────────────────────────────────────────
|
||||
+Our mission is to help people grow *with* AI rather than be displaced by it.
|
||||
+Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation.
|
||||
+Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator.
|
||||
+
|
||||
+Finish every reply with **one concrete suggestion or question** that moves the plan forward.
|
||||
+Never ask for info you already have unless you truly need clarification.
|
||||
+`.trim();
|
||||
|
||||
The user has already provided detailed information about their situation, career goals, finances, education, and more.
|
||||
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
|
||||
const systemPromptOpsCheatSheet = `
|
||||
────────────────────────────────────────────────────────
|
||||
🛠 APTIVA OPS YOU CAN USE ANY TIME
|
||||
────────────────────────────────────────────────────────
|
||||
1. CREATE a milestone (optionally with tasks + impacts)
|
||||
2. UPDATE any field on an existing milestone
|
||||
3. DELETE a milestone that is no longer relevant
|
||||
• You already have permission—no need to ask the user.
|
||||
4. CREATE / UPDATE / DELETE tasks inside a milestone
|
||||
────────────────────────────────────────────────────────
|
||||
When you perform an op, respond with a fenced JSON block
|
||||
_tagged_ \`\`\`ops\`\`\` exactly like this:
|
||||
|
||||
Remember: AptivaAI’s mission is to help the workforce grow *with* AI, not be displaced by it.
|
||||
Just like previous revolutions—industrial, digital—our goal is to show individuals how to
|
||||
utilize AI tools, enhance their own capabilities, and pivot into new opportunities if automation
|
||||
begins to handle older tasks.
|
||||
\`\`\`ops
|
||||
{
|
||||
"milestones":[
|
||||
{ "op":"DELETE", "id":"1234-uuid" },
|
||||
|
||||
Speak in a warm, empathetic tone. Validate the user's ambitions,
|
||||
explain how to break down big goals into realistic steps,
|
||||
and highlight how AI can serve as a *collaborative* tool rather than a rival.
|
||||
{ "op":"UPDATE", "id":"5678-uuid",
|
||||
"patch":{ "date":"2026-02-01", "title":"New title" } },
|
||||
|
||||
Reference the user's location and any relevant experiences or ambitions they've shared.
|
||||
Validate their ambitions, explain how to break down big goals into realistic steps,
|
||||
and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory.
|
||||
{ "op":"CREATE",
|
||||
"data":{
|
||||
"title":"Finish AWS Solutions Architect cert",
|
||||
"type":"Career",
|
||||
"date":"2026-06-01",
|
||||
"description":"Study + exam",
|
||||
"tasks":[
|
||||
{ "title":"Book exam", "due_date":"2026-03-15" }
|
||||
],
|
||||
"impacts":[
|
||||
{ "impact_type":"cost", "direction":"subtract",
|
||||
"amount":350, "start_date":"2026-03-15" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
If the user has mentioned ambitious financial or lifestyle goals (e.g., wanting to buy a Ferrari,
|
||||
become a millionaire, etc.), acknowledge them as "bold" or "exciting," and clarify
|
||||
how the user might move toward them via skill-building, networking, or
|
||||
other relevant steps.
|
||||
|
||||
Use bullet points to restate user goals or interests.
|
||||
End with an open-ended question about what they'd like to tackle next in their plan.
|
||||
|
||||
Do not re-ask for the details below unless you need clarifications.
|
||||
Reflect the user's actual data. Avoid purely generic responses.
|
||||
⚠️ If you’re not changing milestones, skip the ops block entirely. All milestone titles are already 3–5 words; use them verbatim when the user refers to a milestone by name.
|
||||
`.trim();
|
||||
|
||||
const systemPromptStatusSituation = `
|
||||
@ -862,6 +906,13 @@ ${combinedStatusSituation}
|
||||
const systemPromptDetailedContext = `
|
||||
[DETAILED USER PROFILE & CONTEXT]
|
||||
${summaryText}
|
||||
`.trim();
|
||||
|
||||
const dynMilestonePrompt = `
|
||||
[CURRENT MILESTONES]
|
||||
(id | date)
|
||||
${miniGrid}
|
||||
You may UPDATE or DELETE any of these.
|
||||
`.trim();
|
||||
|
||||
const systemPromptMilestoneFormat = `
|
||||
@ -898,18 +949,38 @@ RESPOND ONLY with valid JSON in this shape:
|
||||
Otherwise, answer normally.
|
||||
`.trim();
|
||||
|
||||
|
||||
const avoidBlock = existingTitles.length
|
||||
? "\nAVOID repeating any of these title|date combinations:\n" +
|
||||
existingTitles.map(t => `- ${t}`).join("\n")
|
||||
: "";
|
||||
|
||||
const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS);
|
||||
|
||||
const firstTurn = chatHistory.length === 0;
|
||||
|
||||
const STATIC_SYSTEM_CARD = `
|
||||
${systemPromptIntro}
|
||||
|
||||
${systemPromptOpsCheatSheet}
|
||||
|
||||
/* Milestone JSON spec, date guard, and avoid-list */
|
||||
${systemPromptMilestoneFormat}
|
||||
${systemPromptDateGuard}
|
||||
`.trim();
|
||||
|
||||
/* How many past exchanges to keep */
|
||||
const MAX_CHAT_TURNS = 6;
|
||||
|
||||
// Build up the final messages array
|
||||
const messagesToSend = [
|
||||
{ role: "system", content: systemPromptIntro },
|
||||
{ role: "system", content: systemPromptOpsCheatSheet },
|
||||
{ role: "system", content: systemPromptStatusSituation },
|
||||
{ role: "system", content: systemPromptDetailedContext },
|
||||
{ role: "system", content: systemPromptMilestoneFormat },
|
||||
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged
|
||||
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock },
|
||||
{ role: "system", content: systemPromptDateGuard },
|
||||
...chatHistory // includes user and assistant messages so far
|
||||
];
|
||||
|
||||
|
@ -18,48 +18,28 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────── SERVER-3 (Premium) ─────────────── */
|
||||
{
|
||||
name: 'server3',
|
||||
script: './backend/server3.js',
|
||||
watch: false, // set true if you want auto-reload in dev
|
||||
name : 'server3',
|
||||
script : './backend/server3.js',
|
||||
watch : false,
|
||||
|
||||
env_development: {
|
||||
NODE_ENV : 'development',
|
||||
/* 👇 everything lives here, nothing else to pass at start-time */
|
||||
env: {
|
||||
NODE_ENV : 'production',
|
||||
PREMIUM_PORT : 5002,
|
||||
|
||||
/* Twilio */
|
||||
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
|
||||
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
|
||||
TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6',
|
||||
|
||||
/* DB */
|
||||
DB_HOST : '34.67.180.54',
|
||||
DB_PORT : 3306,
|
||||
DB_USER : 'sqluser',
|
||||
DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
|
||||
DB_NAME : 'user_profile_db'
|
||||
},
|
||||
DB_NAME : 'user_profile_db',
|
||||
|
||||
TWILIO_ACCOUNT_SID : 'AC…',
|
||||
TWILIO_AUTH_TOKEN : 'fb89…',
|
||||
TWILIO_MESSAGING_SERVICE_SID : 'MG…'
|
||||
}
|
||||
}
|
||||
|
||||
env_production: {
|
||||
NODE_ENV : 'development',
|
||||
PREMIUM_PORT : 5002,
|
||||
|
||||
/* Twilio */
|
||||
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
|
||||
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
|
||||
TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6',
|
||||
|
||||
/* DB */
|
||||
DB_HOST : '34.67.180.54',
|
||||
DB_PORT : 3306,
|
||||
DB_USER : 'sqluser',
|
||||
DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
|
||||
DB_NAME : 'user_profile_db'
|
||||
},
|
||||
|
||||
},
|
||||
],
|
||||
|
||||
deploy : {
|
||||
|
@ -114,6 +114,16 @@ export default function CareerCoach({
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
// chat history persistence
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('coachChat:'+careerProfileId);
|
||||
if (saved) setMessages(JSON.parse(saved));
|
||||
}, [careerProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
|
||||
}, [messages, careerProfileId]);
|
||||
|
||||
/* -------------- intro ---------------- */
|
||||
useEffect(() => {
|
||||
if (!scenarioRow) return;
|
||||
@ -319,7 +329,7 @@ I'm here to support you with personalized coaching. What would you like to focus
|
||||
disabled={loading}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
|
||||
>
|
||||
AI Growth Plan
|
||||
Grow Career with AI
|
||||
</button>
|
||||
{/* pushes Edit Goals to the far right */}
|
||||
<div className="flex-grow"></div>
|
||||
|
@ -349,6 +349,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||||
const [strippedSocCode, setStrippedSocCode] = useState(null);
|
||||
const [salaryData, setSalaryData] = useState(null);
|
||||
const [economicProjections, setEconomicProjections] = useState(null);
|
||||
const [salaryLoading, setSalaryLoading] = useState(false);
|
||||
const [econLoading, setEconLoading] = useState(false);
|
||||
|
||||
// Milestones & Projection
|
||||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||||
@ -587,7 +589,26 @@ useEffect(() => {
|
||||
|
||||
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
financialProfile &&
|
||||
scenarioRow &&
|
||||
collegeProfile &&
|
||||
scenarioMilestones.length
|
||||
) {
|
||||
buildProjection(scenarioMilestones); // uses the latest scenarioMilestones
|
||||
}
|
||||
}, [
|
||||
financialProfile,
|
||||
scenarioRow,
|
||||
collegeProfile,
|
||||
scenarioMilestones,
|
||||
simulationYears,
|
||||
interestStrategy,
|
||||
flatAnnualRate,
|
||||
randomRangeMin,
|
||||
randomRangeMax
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recommendations.length > 0) {
|
||||
@ -823,8 +844,12 @@ try {
|
||||
useEffect(() => {
|
||||
// show blank state instantly whenever the SOC or area changes
|
||||
setSalaryData(null);
|
||||
if (!strippedSocCode) return;
|
||||
setSalaryLoading(true);
|
||||
|
||||
if (!strippedSocCode) {
|
||||
setSalaryLoading(false);
|
||||
return;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
@ -833,11 +858,13 @@ useEffect(() => {
|
||||
|
||||
if (res.ok) {
|
||||
setSalaryData(await res.json());
|
||||
setSalaryLoading(false);
|
||||
} else {
|
||||
console.error('[Salary fetch]', res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
|
||||
setSalaryLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -848,7 +875,11 @@ useEffect(() => {
|
||||
/* 7) Economic Projections ---------------------------------------- */
|
||||
useEffect(() => {
|
||||
setEconomicProjections(null);
|
||||
if (!strippedSocCode || !userState) return;
|
||||
setEconLoading(true);
|
||||
if (!strippedSocCode || !userState) {
|
||||
setEconLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctrl = new AbortController();
|
||||
(async () => {
|
||||
@ -861,11 +892,13 @@ useEffect(() => {
|
||||
|
||||
if (res.ok) {
|
||||
setEconomicProjections(await res.json());
|
||||
setEconLoading(false);
|
||||
} else {
|
||||
console.error('[Econ fetch]', res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
|
||||
setEconLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -874,16 +907,10 @@ useEffect(() => {
|
||||
|
||||
|
||||
// 8) Build financial projection
|
||||
async function buildProjection() {
|
||||
async function buildProjection(milestones) {
|
||||
if (!milestones?.length) return;
|
||||
const allMilestones = milestones;
|
||||
try {
|
||||
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
|
||||
const mr = await authFetch(milUrl);
|
||||
if (!mr.ok) {
|
||||
console.error('Failed to fetch milestones =>', mr.status);
|
||||
return;
|
||||
}
|
||||
const md = await mr.json();
|
||||
const allMilestones = md.milestones || [];
|
||||
setScenarioMilestones(allMilestones);
|
||||
|
||||
// fetch impacts
|
||||
@ -1037,7 +1064,7 @@ useEffect(() => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||
buildProjection();
|
||||
fetchMilestones();
|
||||
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
|
||||
|
||||
|
||||
@ -1174,29 +1201,27 @@ const onEditMilestone = useCallback((m) => {
|
||||
setMilestoneForModal(m); // open modal
|
||||
}, []);
|
||||
|
||||
const currentIdRef = useRef(null);
|
||||
|
||||
/* 1️⃣ The only deps it really needs */
|
||||
const fetchMilestones = useCallback(async () => {
|
||||
if (!careerProfileId) {
|
||||
setScenarioMilestones([]);
|
||||
return;
|
||||
}
|
||||
if (!careerProfileId) return;
|
||||
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const [profRes, uniRes] = await Promise.all([
|
||||
authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`),
|
||||
authFetch(`${apiURL}/premium/milestones?careerProfileId=universal`)
|
||||
]);
|
||||
if (!profRes.ok || !uniRes.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const allMilestones = data.milestones || [];
|
||||
setScenarioMilestones(allMilestones);
|
||||
const [{ milestones: profMs }, { milestones: uniMs }] =
|
||||
await Promise.all([profRes.json(), uniRes.json()]);
|
||||
|
||||
/* impacts (optional – only if you still need them in CR) */
|
||||
// ... fetch impacts here if CareerRoadmap charts rely on them ...
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching milestones', err);
|
||||
}
|
||||
}, [careerProfileId, apiURL]);
|
||||
const merged = [...profMs, ...uniMs];
|
||||
setScenarioMilestones(merged);
|
||||
if (financialProfile && scenarioRow && collegeProfile) {
|
||||
buildProjection(merged);
|
||||
} // single rebuild
|
||||
}, [financialProfile, scenarioRow, careerProfileId, apiURL]); // ← NOTICE: no buildProjection here
|
||||
|
||||
|
||||
return (
|
||||
@ -1248,7 +1273,13 @@ const fetchMilestones = useCallback(async () => {
|
||||
|
||||
{/* 2) Salary Benchmarks */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{salaryData?.regional && (
|
||||
{salaryLoading && (
|
||||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||||
<p className="text-sm text-gray-500">Loading salary data…</p>
|
||||
</div>
|
||||
)}
|
||||
{!salaryLoading && salaryData?.regional && (
|
||||
|
||||
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||||
<h4 className="font-medium mb-2">
|
||||
Regional Salary Data ({userArea || 'U.S.'})
|
||||
@ -1281,7 +1312,8 @@ const fetchMilestones = useCallback(async () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{salaryData?.national && (
|
||||
|
||||
{!salaryLoading && salaryData?.national && (
|
||||
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||||
<h4 className="font-medium mb-2">National Salary Data</h4>
|
||||
<p>
|
||||
@ -1310,14 +1342,26 @@ const fetchMilestones = useCallback(async () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!salaryLoading && !salaryData?.regional && !salaryData?.national && (
|
||||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||||
<p className="text-sm text-gray-500">No salary data found.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3) Economic Projections */}
|
||||
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
|
||||
{economicProjections?.state && (
|
||||
{econLoading && (
|
||||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||||
<p className="text-sm text-gray-500">Loading projections…</p>
|
||||
</div>
|
||||
)}
|
||||
{!econLoading && economicProjections?.state && (
|
||||
<EconomicProjectionsBar data={economicProjections.state} />
|
||||
)}
|
||||
{economicProjections?.national && (
|
||||
{!econLoading && economicProjections?.national && (
|
||||
<EconomicProjectionsBar data={economicProjections.national} />
|
||||
)}
|
||||
</div>
|
||||
@ -1485,7 +1529,9 @@ const fetchMilestones = useCallback(async () => {
|
||||
fetchMilestones={fetchMilestones} // helper to refresh list
|
||||
onClose={(didSave) => {
|
||||
setMilestoneForModal(false); // or setShowMilestoneModal(false)
|
||||
if (didSave) fetchMilestones();
|
||||
if (didSave) {
|
||||
fetchMilestones();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -27,7 +27,6 @@ export default function MilestoneEditModal({
|
||||
const [milestones, setMilestones] = useState(incomingMils);
|
||||
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
||||
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
||||
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
|
||||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||||
const [newMilestoneData, setNewMilestoneData] = useState({
|
||||
title: "",
|
||||
@ -40,223 +39,279 @@ export default function MilestoneEditModal({
|
||||
});
|
||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||||
|
||||
function toSqlDate(str = '') {
|
||||
// Handles '', null, undefined gracefully
|
||||
return str.slice(0, 10); // "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
/* keep milestones in sync with prop */
|
||||
useEffect(() => {
|
||||
setMilestones(incomingMils);
|
||||
}, [incomingMils]);
|
||||
|
||||
/* ────────────────────────────────
|
||||
Inline‑edit helpers (trimmed copy of ScenarioContainer logic)
|
||||
*/
|
||||
const loadMilestoneImpacts = useCallback(async (m) => {
|
||||
/* ────────────────────────────────
|
||||
Inline-edit helpers
|
||||
──────────────────────────────────*/
|
||||
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone
|
||||
|
||||
/* 1️⃣ fetch impacts + open editor */
|
||||
const loadMilestoneImpacts = useCallback(async (m) => {
|
||||
try {
|
||||
const impRes = await authFetch(
|
||||
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
||||
);
|
||||
if (!impRes.ok) throw new Error("impact fetch failed");
|
||||
const data = await impRes.json();
|
||||
const impacts = (data.impacts || []).map((imp) => ({
|
||||
id: imp.id,
|
||||
impact_type: imp.impact_type || "ONE_TIME",
|
||||
direction: imp.direction || "subtract",
|
||||
amount: imp.amount || 0,
|
||||
start_date: imp.start_date || "",
|
||||
end_date: imp.end_date || ""
|
||||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||||
if (!res.ok) throw new Error('impact fetch failed');
|
||||
const json = await res.json();
|
||||
|
||||
const impacts = (json.impacts || []).map(imp => ({
|
||||
id : imp.id,
|
||||
impact_type : imp.impact_type || 'ONE_TIME',
|
||||
direction : imp.direction || 'subtract',
|
||||
amount : imp.amount || 0,
|
||||
start_date : toSqlDate(imp.start_date) || '',
|
||||
end_date : toSqlDate(imp.end_date) || ''
|
||||
}));
|
||||
|
||||
setNewMilestoneMap((prev) => ({
|
||||
/* editable copy for the form */
|
||||
setNewMilestoneMap(prev => ({
|
||||
...prev,
|
||||
[m.id]: {
|
||||
title: m.title || "",
|
||||
description: m.description || "",
|
||||
date: m.date || "",
|
||||
progress: m.progress || 0,
|
||||
newSalary: m.new_salary || "",
|
||||
title : m.title || '',
|
||||
description : m.description || '',
|
||||
date : toSqlDate(m.date) || '',
|
||||
progress : m.progress || 0,
|
||||
newSalary : m.new_salary || '',
|
||||
impacts,
|
||||
isUniversal: m.is_universal ? 1 : 0
|
||||
isUniversal : m.is_universal ? 1 : 0
|
||||
}
|
||||
}));
|
||||
setEditingMilestoneId(m.id);
|
||||
setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] }));
|
||||
|
||||
/* snapshot the IDs that existed when editing started */
|
||||
setOriginalImpactIdsMap(prev => ({
|
||||
...prev,
|
||||
[m.id]: impacts.map(i => i.id) // array of strings
|
||||
}));
|
||||
|
||||
setEditingMilestoneId(m.id); // open the accordion
|
||||
} catch (err) {
|
||||
console.error("loadImpacts", err);
|
||||
console.error('loadImpacts', err);
|
||||
}
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
const handleEditMilestoneInline = (m) => {
|
||||
if (editingMilestoneId === m.id) {
|
||||
setEditingMilestoneId(null);
|
||||
} else {
|
||||
loadMilestoneImpacts(m);
|
||||
/* 2️⃣ toggle open / close */
|
||||
const handleEditMilestoneInline = (milestone) => {
|
||||
setEditingMilestoneId((curr) =>
|
||||
curr === milestone.id ? null : milestone.id
|
||||
);
|
||||
if (editingMilestoneId !== milestone.id) loadMilestoneImpacts(milestone);
|
||||
};
|
||||
|
||||
/* 3️⃣ generic field updater for one impact row */
|
||||
const updateInlineImpact = (mid, idx, field, value) => {
|
||||
setNewMilestoneMap(prev => {
|
||||
const m = prev[mid];
|
||||
if (!m) return prev;
|
||||
const impacts = [...m.impacts];
|
||||
impacts[idx] = { ...impacts[idx], [field]: value };
|
||||
return { ...prev, [mid]: { ...m, impacts } };
|
||||
});
|
||||
};
|
||||
|
||||
/* 4️⃣ add an empty impact row */
|
||||
const addInlineImpact = (mid) => {
|
||||
setNewMilestoneMap(prev => {
|
||||
const m = prev[mid];
|
||||
if (!m) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[mid]: {
|
||||
...m,
|
||||
impacts: [
|
||||
...m.impacts,
|
||||
{
|
||||
impact_type : 'ONE_TIME',
|
||||
direction : 'subtract',
|
||||
amount : 0,
|
||||
start_date : '',
|
||||
end_date : ''
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const updateInlineImpact = (milestoneId, idx, field, value) => {
|
||||
setNewMilestoneMap((prev) => {
|
||||
const copy = { ...prev };
|
||||
const item = copy[milestoneId];
|
||||
if (!item) return prev;
|
||||
const impactsClone = [...item.impacts];
|
||||
impactsClone[idx] = { ...impactsClone[idx], [field]: value };
|
||||
copy[milestoneId] = { ...item, impacts: impactsClone };
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const addInlineImpact = (milestoneId) => {
|
||||
setNewMilestoneMap((prev) => {
|
||||
const itm = prev[milestoneId];
|
||||
if (!itm) return prev;
|
||||
const impactsClone = [...itm.impacts, {
|
||||
impact_type: "ONE_TIME",
|
||||
direction: "subtract",
|
||||
amount: 0,
|
||||
start_date: "",
|
||||
end_date: ""
|
||||
}];
|
||||
return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } };
|
||||
/* 5️⃣ remove one impact row (local only – diff happens on save) */
|
||||
const removeInlineImpact = (mid, idx) => {
|
||||
setNewMilestoneMap(prev => {
|
||||
const m = prev[mid];
|
||||
if (!m) return prev;
|
||||
const clone = [...m.impacts];
|
||||
clone.splice(idx, 1);
|
||||
return { ...prev, [mid]: { ...m, impacts: clone } };
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const removeInlineImpact = (mid, idx) => {
|
||||
setNewMilestoneMap((prev) => {
|
||||
const itm = prev[mid];
|
||||
if (!itm) return prev;
|
||||
const impactsClone = [...itm.impacts];
|
||||
const [removed] = impactsClone.splice(idx, 1);
|
||||
setImpactsToDeleteMap((p) => ({
|
||||
...p,
|
||||
[mid]: [...(p[mid] || []), removed.id].filter(Boolean)
|
||||
}));
|
||||
return { ...prev, [mid]: { ...itm, impacts: impactsClone } };
|
||||
});
|
||||
};
|
||||
|
||||
const saveInlineMilestone = async (m) => {
|
||||
/* 6️⃣ persist the edits – PUT milestone, diff impacts */
|
||||
const saveInlineMilestone = async (m) => {
|
||||
const data = newMilestoneMap[m.id];
|
||||
if (!data) return;
|
||||
|
||||
/* --- update the milestone header --- */
|
||||
const payload = {
|
||||
milestone_type: "Financial",
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: data.date,
|
||||
career_profile_id: careerProfileId,
|
||||
progress: data.progress,
|
||||
status: data.progress >= 100 ? "completed" : "planned",
|
||||
new_salary: data.newSalary ? parseFloat(data.newSalary) : null,
|
||||
is_universal: data.isUniversal || 0
|
||||
milestone_type : 'Financial',
|
||||
title : data.title,
|
||||
description : data.description,
|
||||
date : toSqlDate(data.date),
|
||||
career_profile_id : careerProfileId,
|
||||
progress : data.progress,
|
||||
status : data.progress >= 100 ? 'completed' : 'planned',
|
||||
new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
|
||||
is_universal : data.isUniversal || 0
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const saved = await res.json();
|
||||
|
||||
/* impacts */
|
||||
const toDelete = impactsToDeleteMap[m.id] || [];
|
||||
/* --- figure out what changed ---------------------------------- */
|
||||
const originalIds = originalImpactIdsMap[m.id] || [];
|
||||
const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean);
|
||||
const toDelete = originalIds.filter(id => !currentIds.includes(id));
|
||||
|
||||
/* --- deletions first --- */
|
||||
for (const delId of toDelete) {
|
||||
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
|
||||
method: "DELETE"
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/* --- creates / updates --- */
|
||||
for (const imp of data.impacts) {
|
||||
const impPayload = {
|
||||
milestone_id: saved.id,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: parseFloat(imp.amount) || 0,
|
||||
start_date: imp.start_date || null,
|
||||
end_date: imp.end_date || null
|
||||
milestone_id : saved.id,
|
||||
impact_type : imp.impact_type,
|
||||
direction : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : toSqlDate(imp.start_date) || null,
|
||||
end_date : toSqlDate(imp.end_date) || null
|
||||
};
|
||||
|
||||
if (imp.id) {
|
||||
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(impPayload)
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify(impPayload)
|
||||
});
|
||||
} else {
|
||||
await authFetch("/api/premium/milestone-impacts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(impPayload)
|
||||
await authFetch('/api/premium/milestone-impacts', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify(impPayload)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* --- refresh + close --- */
|
||||
await fetchMilestones();
|
||||
setEditingMilestoneId(null);
|
||||
onClose(true);
|
||||
|
||||
} catch (err) {
|
||||
alert("Failed to save milestone");
|
||||
alert('Failed to save milestone');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* brand‑new milestone helpers (trimmed) */
|
||||
const addNewImpactToNewMilestone = () => {
|
||||
setNewMilestoneData((p) => ({
|
||||
...p,
|
||||
/* ───────────── misc helpers the JSX still calls ───────────── */
|
||||
|
||||
/* A) delete one milestone row altogether */
|
||||
const deleteMilestone = async (milestone) => {
|
||||
if (!window.confirm(`Delete “${milestone.title}” ?`)) return;
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`/api/premium/milestones/${milestone.id}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await fetchMilestones(); // refresh parent list
|
||||
onClose(true); // bubble up that something changed
|
||||
} catch (err) {
|
||||
alert('Failed to delete milestone');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
/* B) add a blank impact row while creating a brand-new milestone */
|
||||
const addNewImpactToNewMilestone = () => {
|
||||
setNewMilestoneData(prev => ({
|
||||
...prev,
|
||||
impacts: [
|
||||
...p.impacts,
|
||||
...prev.impacts,
|
||||
{
|
||||
impact_type: "ONE_TIME",
|
||||
direction: "subtract",
|
||||
amount: 0,
|
||||
start_date: "",
|
||||
end_date: ""
|
||||
impact_type : 'ONE_TIME',
|
||||
direction : 'subtract',
|
||||
amount : 0,
|
||||
start_date : '',
|
||||
end_date : ''
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
/* C) create an entirely new milestone + its impacts */
|
||||
const saveNewMilestone = async () => {
|
||||
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
|
||||
alert('Need title and date'); return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title : newMilestoneData.title,
|
||||
description : newMilestoneData.description,
|
||||
date : toSqlDate(newMilestoneData.date),
|
||||
career_profile_id: careerProfileId,
|
||||
progress : newMilestoneData.progress,
|
||||
status : newMilestoneData.progress >= 100 ? 'completed' : 'planned',
|
||||
is_universal : newMilestoneData.isUniversal || 0
|
||||
};
|
||||
|
||||
const saveNewMilestone = async () => {
|
||||
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
|
||||
alert("Need title and date");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: newMilestoneData.title,
|
||||
description: newMilestoneData.description,
|
||||
date: newMilestoneData.date,
|
||||
career_profile_id: careerProfileId,
|
||||
progress: newMilestoneData.progress,
|
||||
status: newMilestoneData.progress >= 100 ? "completed" : "planned",
|
||||
is_universal: newMilestoneData.isUniversal || 0
|
||||
};
|
||||
try {
|
||||
const res = await authFetch("/api/premium/milestone", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
const res = await authFetch('/api/premium/milestone', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const created = Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
|
||||
const created =
|
||||
Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
|
||||
|
||||
// impacts
|
||||
/* impacts for the new milestone */
|
||||
for (const imp of newMilestoneData.impacts) {
|
||||
const impPayload = {
|
||||
milestone_id: created.id,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: parseFloat(imp.amount) || 0,
|
||||
start_date: imp.start_date || null,
|
||||
end_date: imp.end_date || null
|
||||
milestone_id : created.id,
|
||||
impact_type : imp.impact_type,
|
||||
direction : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : toSqlDate(imp.start_date) || null,
|
||||
end_date : toSqlDate(imp.end_date) || null
|
||||
};
|
||||
await authFetch("/api/premium/milestone-impacts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(impPayload)
|
||||
await authFetch('/api/premium/milestone-impacts', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify(impPayload)
|
||||
});
|
||||
}
|
||||
await fetchMilestones();
|
||||
|
||||
await fetchMilestones(); // refresh list
|
||||
setAddingNewMilestone(false); // collapse the new-mile form
|
||||
onClose(true);
|
||||
} catch (err) {
|
||||
alert("Failed to save milestone");
|
||||
alert('Failed to save milestone');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* ────────────────────────────────
|
||||
Render
|
||||
@ -296,6 +351,12 @@ export default function MilestoneEditModal({
|
||||
<Button onClick={() => handleEditMilestoneInline(m)}>
|
||||
{hasEditOpen ? "Cancel" : "Edit"}
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginLeft: "0.5rem", color: "black", backgroundColor: "red" }}
|
||||
onClick={() => deleteMilestone(m)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<p>{m.description}</p>
|
||||
<p>
|
||||
@ -343,7 +404,7 @@ export default function MilestoneEditModal({
|
||||
/>
|
||||
{/* impacts */}
|
||||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<h6>Impacts</h6>
|
||||
<h6>Financial Impacts</h6>
|
||||
{data.impacts?.map((imp, idx) => (
|
||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||||
<label>Type:</label>
|
||||
@ -351,6 +412,7 @@ export default function MilestoneEditModal({
|
||||
value={imp.impact_type}
|
||||
onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)}
|
||||
>
|
||||
<option value="salary">Salary (annual)</option>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
@ -389,7 +451,7 @@ export default function MilestoneEditModal({
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => addInlineImpact(m.id)}>+ Impact</Button>
|
||||
<Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
|
||||
</div>
|
||||
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
||||
</div>
|
||||
@ -441,6 +503,7 @@ export default function MilestoneEditModal({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="salary">Salary (annual)</option>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
@ -516,7 +579,7 @@ export default function MilestoneEditModal({
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={addNewImpactToNewMilestone}>+ Impact</Button>
|
||||
<Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
|
||||
</div>
|
||||
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
||||
</div>
|
||||
|
@ -30,7 +30,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://dev1.aptivaai.com/api/signin', {
|
||||
const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
@ -105,7 +105,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don’t have an account?{' '}
|
||||
<Link
|
||||
to="/signup"
|
||||
//to="/signup" // <- here
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Sign Up
|
||||
|
@ -371,40 +371,47 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
||||
}
|
||||
|
||||
/************************************************
|
||||
* 7.3 MILESTONE IMPACTS – strict number handling
|
||||
* 7.3 MILESTONE IMPACTS – safe number handling
|
||||
************************************************/
|
||||
let extraImpactsThisMonth = 0;
|
||||
let extraImpactsThisMonth = 0; // affects expenses
|
||||
let salaryAdjustThisMonth = 0; // affects gross income
|
||||
|
||||
milestoneImpacts.forEach((rawImpact) => {
|
||||
/* --- safety / coercion ------------------------------------------------ */
|
||||
const amount = Number(rawImpact.amount) || 0; // ← always a number
|
||||
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // 'ONE_TIME' | 'MONTHLY'
|
||||
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // 'add' | 'subtract'
|
||||
/* ---------- 1. Normalise ---------- */
|
||||
const amount = Number(rawImpact.amount) || 0;
|
||||
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME
|
||||
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract
|
||||
|
||||
/* --- date math -------------------------------------------------------- */
|
||||
const startDateClamped = moment(rawImpact.start_date).startOf('month');
|
||||
let startOffset = startDateClamped.diff(scenarioStartClamped, 'months');
|
||||
if (startOffset < 0) startOffset = 0;
|
||||
/* ---------- 2. Work out timing ---------- */
|
||||
const startDate = moment(rawImpact.start_date).startOf('month');
|
||||
const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null;
|
||||
|
||||
let endOffset = Infinity;
|
||||
if (rawImpact.end_date && rawImpact.end_date.trim() !== '') {
|
||||
const endDateClamped = moment(rawImpact.end_date).startOf('month');
|
||||
endOffset = endDateClamped.diff(scenarioStartClamped, 'months');
|
||||
if (endOffset < 0) endOffset = 0;
|
||||
}
|
||||
const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months'));
|
||||
const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity;
|
||||
|
||||
/* --- apply impact ----------------------------------------------------- */
|
||||
const applyAmount = (dir) =>
|
||||
dir === 'add' ? (baseMonthlyIncome += amount) : (extraImpactsThisMonth += amount);
|
||||
const isActiveThisMonth =
|
||||
(type === 'ONE_TIME' && monthIndex === startOffset) ||
|
||||
(type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset);
|
||||
|
||||
if (type === 'ONE_TIME') {
|
||||
if (monthIndex === startOffset) applyAmount(direction);
|
||||
if (!isActiveThisMonth) return; // skip to next impact
|
||||
|
||||
/* ---------- 3. Apply the impact ---------- */
|
||||
const sign = direction === 'add' ? 1 : -1;
|
||||
|
||||
if (type.startsWith('SALARY')) {
|
||||
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
|
||||
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
|
||||
salaryAdjustThisMonth += sign * monthlyDelta;
|
||||
} else {
|
||||
// MONTHLY (or anything else) – apply for the whole span
|
||||
if (monthIndex >= startOffset && monthIndex <= endOffset) applyAmount(direction);
|
||||
// MONTHLY or ONE_TIME expenses / windfalls
|
||||
extraImpactsThisMonth += sign * amount;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 4. Reflect deltas in this month’s calc ---------- */
|
||||
baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
|
||||
// `extraImpactsThisMonth` is already added to expenses later in the loop
|
||||
|
||||
|
||||
/************************************************
|
||||
* 7.4 CALCULATE TAXES
|
||||
|
Loading…
Reference in New Issue
Block a user