Fixed some things, broke some others.
This commit is contained in:
parent
47819493cc
commit
1868c4348a
@ -1,29 +1,15 @@
|
|||||||
// backend/config/mysqlPool.js
|
// backend/config/mysqlPool.js
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const pool = mysql.createPool({
|
||||||
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 = {
|
|
||||||
host : process.env.DB_HOST || '127.0.0.1',
|
host : process.env.DB_HOST || '127.0.0.1',
|
||||||
port : process.env.DB_PORT || 3306,
|
port : process.env.DB_PORT || 3306,
|
||||||
user : process.env.DB_USER || 'root',
|
user : process.env.DB_USER || 'root',
|
||||||
password: process.env.DB_PASSWORD || '',
|
password : process.env.DB_PASSWORD || '',
|
||||||
};
|
database : process.env.DB_NAME || 'user_profile_db',
|
||||||
}
|
waitForConnections : true,
|
||||||
poolConfig.database = process.env.DB_NAME || 'user_profile_db';
|
connectionLimit : 10,
|
||||||
poolConfig.waitForConnections = true;
|
...(process.env.DB_SOCKET ? { socketPath: process.env.DB_SOCKET } : {})
|
||||||
poolConfig.connectionLimit = 10;
|
});
|
||||||
|
|
||||||
export default mysql.createPool(poolConfig);
|
export default pool;
|
@ -1,35 +1,31 @@
|
|||||||
//
|
// ─── server3.js ────────────────────────────────────────────────────────────
|
||||||
// server3.js - MySQL Version
|
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 express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import fetch from "node-fetch";
|
import fetch from 'node-fetch';
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pkg from 'pdfjs-dist';
|
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 './jobs/reminderCron.js';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import { createReminder } from './utils/smsService.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";
|
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||||||
|
|
||||||
@ -49,6 +45,7 @@ function internalFetch(req, url, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 2) Basic middlewares
|
// 2) Basic middlewares
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(express.json({ limit: '5mb' }));
|
app.use(express.json({ limit: '5mb' }));
|
||||||
@ -73,6 +70,8 @@ const authenticatePremiumUser = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pool = db;
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
CAREER PROFILE ENDPOINTS
|
CAREER PROFILE ENDPOINTS
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
@ -513,17 +512,26 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
let existingTitles = [];
|
let existingTitles = [];
|
||||||
|
let miniGrid = "-none-"; // slim grid
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
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
|
FROM milestones
|
||||||
WHERE user_id = ? AND career_profile_id = ?`,
|
WHERE user_id = ? AND career_profile_id = ?`,
|
||||||
[req.id, scenarioRow.id]
|
[req.id, scenarioRow.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
|
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
|
// 1. Helper Functions
|
||||||
// ------------------------------------------------
|
// ------------------------------------------------
|
||||||
@ -824,34 +832,70 @@ ${econText}
|
|||||||
// 5. Construct System-Level Prompts
|
// 5. Construct System-Level Prompts
|
||||||
// ------------------------------------------------
|
// ------------------------------------------------
|
||||||
const systemPromptIntro = `
|
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.
|
const systemPromptOpsCheatSheet = `
|
||||||
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
|
────────────────────────────────────────────────────────
|
||||||
|
🛠 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.
|
\`\`\`ops
|
||||||
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
|
"milestones":[
|
||||||
begins to handle older tasks.
|
{ "op":"DELETE", "id":"1234-uuid" },
|
||||||
|
|
||||||
Speak in a warm, empathetic tone. Validate the user's ambitions,
|
{ "op":"UPDATE", "id":"5678-uuid",
|
||||||
explain how to break down big goals into realistic steps,
|
"patch":{ "date":"2026-02-01", "title":"New title" } },
|
||||||
and highlight how AI can serve as a *collaborative* tool rather than a rival.
|
|
||||||
|
|
||||||
Reference the user's location and any relevant experiences or ambitions they've shared.
|
{ "op":"CREATE",
|
||||||
Validate their ambitions, explain how to break down big goals into realistic steps,
|
"data":{
|
||||||
and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory.
|
"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,
|
⚠️ 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.
|
||||||
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.
|
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const systemPromptStatusSituation = `
|
const systemPromptStatusSituation = `
|
||||||
@ -862,6 +906,13 @@ ${combinedStatusSituation}
|
|||||||
const systemPromptDetailedContext = `
|
const systemPromptDetailedContext = `
|
||||||
[DETAILED USER PROFILE & CONTEXT]
|
[DETAILED USER PROFILE & CONTEXT]
|
||||||
${summaryText}
|
${summaryText}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const dynMilestonePrompt = `
|
||||||
|
[CURRENT MILESTONES]
|
||||||
|
(id | date)
|
||||||
|
${miniGrid}
|
||||||
|
You may UPDATE or DELETE any of these.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const systemPromptMilestoneFormat = `
|
const systemPromptMilestoneFormat = `
|
||||||
@ -898,18 +949,38 @@ RESPOND ONLY with valid JSON in this shape:
|
|||||||
Otherwise, answer normally.
|
Otherwise, answer normally.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
|
||||||
const avoidBlock = existingTitles.length
|
const avoidBlock = existingTitles.length
|
||||||
? "\nAVOID repeating any of these title|date combinations:\n" +
|
? "\nAVOID repeating any of these title|date combinations:\n" +
|
||||||
existingTitles.map(t => `- ${t}`).join("\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
|
// Build up the final messages array
|
||||||
const messagesToSend = [
|
const messagesToSend = [
|
||||||
{ role: "system", content: systemPromptIntro },
|
{ role: "system", content: systemPromptIntro },
|
||||||
|
{ role: "system", content: systemPromptOpsCheatSheet },
|
||||||
{ role: "system", content: systemPromptStatusSituation },
|
{ role: "system", content: systemPromptStatusSituation },
|
||||||
{ role: "system", content: systemPromptDetailedContext },
|
{ role: "system", content: systemPromptDetailedContext },
|
||||||
{ role: "system", content: systemPromptMilestoneFormat },
|
{ 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
|
...chatHistory // includes user and assistant messages so far
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -18,48 +18,28 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ─────────────── SERVER-3 (Premium) ─────────────── */
|
|
||||||
{
|
{
|
||||||
name: 'server3',
|
name : 'server3',
|
||||||
script: './backend/server3.js',
|
script : './backend/server3.js',
|
||||||
watch: false, // set true if you want auto-reload in dev
|
watch : false,
|
||||||
|
|
||||||
env_development: {
|
/* 👇 everything lives here, nothing else to pass at start-time */
|
||||||
NODE_ENV : 'development',
|
env: {
|
||||||
|
NODE_ENV : 'production',
|
||||||
PREMIUM_PORT : 5002,
|
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_HOST : '34.67.180.54',
|
||||||
DB_PORT : 3306,
|
DB_PORT : 3306,
|
||||||
DB_USER : 'sqluser',
|
DB_USER : 'sqluser',
|
||||||
DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
|
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 : {
|
deploy : {
|
||||||
|
@ -114,6 +114,16 @@ export default function CareerCoach({
|
|||||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||||
}, [messages]);
|
}, [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 ---------------- */
|
/* -------------- intro ---------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scenarioRow) return;
|
if (!scenarioRow) return;
|
||||||
@ -319,7 +329,7 @@ I'm here to support you with personalized coaching. What would you like to focus
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
|
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
|
||||||
>
|
>
|
||||||
AI Growth Plan
|
Grow Career with AI
|
||||||
</button>
|
</button>
|
||||||
{/* pushes Edit Goals to the far right */}
|
{/* pushes Edit Goals to the far right */}
|
||||||
<div className="flex-grow"></div>
|
<div className="flex-grow"></div>
|
||||||
|
@ -349,6 +349,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
const [strippedSocCode, setStrippedSocCode] = useState(null);
|
const [strippedSocCode, setStrippedSocCode] = useState(null);
|
||||||
const [salaryData, setSalaryData] = useState(null);
|
const [salaryData, setSalaryData] = useState(null);
|
||||||
const [economicProjections, setEconomicProjections] = useState(null);
|
const [economicProjections, setEconomicProjections] = useState(null);
|
||||||
|
const [salaryLoading, setSalaryLoading] = useState(false);
|
||||||
|
const [econLoading, setEconLoading] = useState(false);
|
||||||
|
|
||||||
// Milestones & Projection
|
// Milestones & Projection
|
||||||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||||||
@ -587,7 +589,26 @@ useEffect(() => {
|
|||||||
|
|
||||||
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (recommendations.length > 0) {
|
if (recommendations.length > 0) {
|
||||||
@ -823,8 +844,12 @@ try {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// show blank state instantly whenever the SOC or area changes
|
// show blank state instantly whenever the SOC or area changes
|
||||||
setSalaryData(null);
|
setSalaryData(null);
|
||||||
if (!strippedSocCode) return;
|
setSalaryLoading(true);
|
||||||
|
|
||||||
|
if (!strippedSocCode) {
|
||||||
|
setSalaryLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@ -833,11 +858,13 @@ useEffect(() => {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSalaryData(await res.json());
|
setSalaryData(await res.json());
|
||||||
|
setSalaryLoading(false);
|
||||||
} else {
|
} else {
|
||||||
console.error('[Salary fetch]', res.status);
|
console.error('[Salary fetch]', res.status);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
|
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
|
||||||
|
setSalaryLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -848,7 +875,11 @@ useEffect(() => {
|
|||||||
/* 7) Economic Projections ---------------------------------------- */
|
/* 7) Economic Projections ---------------------------------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEconomicProjections(null);
|
setEconomicProjections(null);
|
||||||
if (!strippedSocCode || !userState) return;
|
setEconLoading(true);
|
||||||
|
if (!strippedSocCode || !userState) {
|
||||||
|
setEconLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -861,11 +892,13 @@ useEffect(() => {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setEconomicProjections(await res.json());
|
setEconomicProjections(await res.json());
|
||||||
|
setEconLoading(false);
|
||||||
} else {
|
} else {
|
||||||
console.error('[Econ fetch]', res.status);
|
console.error('[Econ fetch]', res.status);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
|
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
|
||||||
|
setEconLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -874,16 +907,10 @@ useEffect(() => {
|
|||||||
|
|
||||||
|
|
||||||
// 8) Build financial projection
|
// 8) Build financial projection
|
||||||
async function buildProjection() {
|
async function buildProjection(milestones) {
|
||||||
|
if (!milestones?.length) return;
|
||||||
|
const allMilestones = milestones;
|
||||||
try {
|
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);
|
setScenarioMilestones(allMilestones);
|
||||||
|
|
||||||
// fetch impacts
|
// fetch impacts
|
||||||
@ -1037,7 +1064,7 @@ useEffect(() => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||||
buildProjection();
|
fetchMilestones();
|
||||||
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
|
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
|
||||||
|
|
||||||
|
|
||||||
@ -1174,29 +1201,27 @@ const onEditMilestone = useCallback((m) => {
|
|||||||
setMilestoneForModal(m); // open modal
|
setMilestoneForModal(m); // open modal
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const currentIdRef = useRef(null);
|
||||||
|
|
||||||
|
/* 1️⃣ The only deps it really needs */
|
||||||
const fetchMilestones = useCallback(async () => {
|
const fetchMilestones = useCallback(async () => {
|
||||||
if (!careerProfileId) {
|
if (!careerProfileId) return;
|
||||||
setScenarioMilestones([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const [profRes, uniRes] = await Promise.all([
|
||||||
const res = await authFetch(
|
authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`),
|
||||||
`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`
|
authFetch(`${apiURL}/premium/milestones?careerProfileId=universal`)
|
||||||
);
|
]);
|
||||||
if (!res.ok) return;
|
if (!profRes.ok || !uniRes.ok) return;
|
||||||
|
|
||||||
const data = await res.json();
|
const [{ milestones: profMs }, { milestones: uniMs }] =
|
||||||
const allMilestones = data.milestones || [];
|
await Promise.all([profRes.json(), uniRes.json()]);
|
||||||
setScenarioMilestones(allMilestones);
|
|
||||||
|
|
||||||
/* impacts (optional – only if you still need them in CR) */
|
const merged = [...profMs, ...uniMs];
|
||||||
// ... fetch impacts here if CareerRoadmap charts rely on them ...
|
setScenarioMilestones(merged);
|
||||||
|
if (financialProfile && scenarioRow && collegeProfile) {
|
||||||
} catch (err) {
|
buildProjection(merged);
|
||||||
console.error('Error fetching milestones', err);
|
} // single rebuild
|
||||||
}
|
}, [financialProfile, scenarioRow, careerProfileId, apiURL]); // ← NOTICE: no buildProjection here
|
||||||
}, [careerProfileId, apiURL]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1248,7 +1273,13 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
|
|
||||||
{/* 2) Salary Benchmarks */}
|
{/* 2) Salary Benchmarks */}
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<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">
|
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||||||
<h4 className="font-medium mb-2">
|
<h4 className="font-medium mb-2">
|
||||||
Regional Salary Data ({userArea || 'U.S.'})
|
Regional Salary Data ({userArea || 'U.S.'})
|
||||||
@ -1281,7 +1312,8 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{salaryData?.national && (
|
|
||||||
|
{!salaryLoading && salaryData?.national && (
|
||||||
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||||||
<h4 className="font-medium mb-2">National Salary Data</h4>
|
<h4 className="font-medium mb-2">National Salary Data</h4>
|
||||||
<p>
|
<p>
|
||||||
@ -1310,14 +1342,26 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 3) Economic Projections */}
|
{/* 3) Economic Projections */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
|
<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} />
|
<EconomicProjectionsBar data={economicProjections.state} />
|
||||||
)}
|
)}
|
||||||
{economicProjections?.national && (
|
{!econLoading && economicProjections?.national && (
|
||||||
<EconomicProjectionsBar data={economicProjections.national} />
|
<EconomicProjectionsBar data={economicProjections.national} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1485,7 +1529,9 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
fetchMilestones={fetchMilestones} // helper to refresh list
|
fetchMilestones={fetchMilestones} // helper to refresh list
|
||||||
onClose={(didSave) => {
|
onClose={(didSave) => {
|
||||||
setMilestoneForModal(false); // or setShowMilestoneModal(false)
|
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 [milestones, setMilestones] = useState(incomingMils);
|
||||||
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
||||||
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
||||||
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
|
|
||||||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||||||
const [newMilestoneData, setNewMilestoneData] = useState({
|
const [newMilestoneData, setNewMilestoneData] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
@ -40,223 +39,279 @@ export default function MilestoneEditModal({
|
|||||||
});
|
});
|
||||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
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 */
|
/* keep milestones in sync with prop */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMilestones(incomingMils);
|
setMilestones(incomingMils);
|
||||||
}, [incomingMils]);
|
}, [incomingMils]);
|
||||||
|
|
||||||
/* ────────────────────────────────
|
/* ────────────────────────────────
|
||||||
Inline‑edit helpers (trimmed copy of ScenarioContainer logic)
|
Inline-edit helpers
|
||||||
*/
|
──────────────────────────────────*/
|
||||||
const loadMilestoneImpacts = useCallback(async (m) => {
|
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone
|
||||||
|
|
||||||
|
/* 1️⃣ fetch impacts + open editor */
|
||||||
|
const loadMilestoneImpacts = useCallback(async (m) => {
|
||||||
try {
|
try {
|
||||||
const impRes = await authFetch(
|
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||||||
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
if (!res.ok) throw new Error('impact fetch failed');
|
||||||
);
|
const json = await res.json();
|
||||||
if (!impRes.ok) throw new Error("impact fetch failed");
|
|
||||||
const data = await impRes.json();
|
const impacts = (json.impacts || []).map(imp => ({
|
||||||
const impacts = (data.impacts || []).map((imp) => ({
|
id : imp.id,
|
||||||
id: imp.id,
|
impact_type : imp.impact_type || 'ONE_TIME',
|
||||||
impact_type: imp.impact_type || "ONE_TIME",
|
direction : imp.direction || 'subtract',
|
||||||
direction: imp.direction || "subtract",
|
amount : imp.amount || 0,
|
||||||
amount: imp.amount || 0,
|
start_date : toSqlDate(imp.start_date) || '',
|
||||||
start_date: imp.start_date || "",
|
end_date : toSqlDate(imp.end_date) || ''
|
||||||
end_date: imp.end_date || ""
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setNewMilestoneMap((prev) => ({
|
/* editable copy for the form */
|
||||||
|
setNewMilestoneMap(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[m.id]: {
|
[m.id]: {
|
||||||
title: m.title || "",
|
title : m.title || '',
|
||||||
description: m.description || "",
|
description : m.description || '',
|
||||||
date: m.date || "",
|
date : toSqlDate(m.date) || '',
|
||||||
progress: m.progress || 0,
|
progress : m.progress || 0,
|
||||||
newSalary: m.new_salary || "",
|
newSalary : m.new_salary || '',
|
||||||
impacts,
|
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) {
|
} catch (err) {
|
||||||
console.error("loadImpacts", err);
|
console.error('loadImpacts', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditMilestoneInline = (m) => {
|
/* 2️⃣ toggle open / close */
|
||||||
if (editingMilestoneId === m.id) {
|
const handleEditMilestoneInline = (milestone) => {
|
||||||
setEditingMilestoneId(null);
|
setEditingMilestoneId((curr) =>
|
||||||
} else {
|
curr === milestone.id ? null : milestone.id
|
||||||
loadMilestoneImpacts(m);
|
);
|
||||||
|
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) => {
|
/* 5️⃣ remove one impact row (local only – diff happens on save) */
|
||||||
setNewMilestoneMap((prev) => {
|
const removeInlineImpact = (mid, idx) => {
|
||||||
const itm = prev[milestoneId];
|
setNewMilestoneMap(prev => {
|
||||||
if (!itm) return prev;
|
const m = prev[mid];
|
||||||
const impactsClone = [...itm.impacts, {
|
if (!m) return prev;
|
||||||
impact_type: "ONE_TIME",
|
const clone = [...m.impacts];
|
||||||
direction: "subtract",
|
clone.splice(idx, 1);
|
||||||
amount: 0,
|
return { ...prev, [mid]: { ...m, impacts: clone } };
|
||||||
start_date: "",
|
|
||||||
end_date: ""
|
|
||||||
}];
|
|
||||||
return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } };
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeInlineImpact = (mid, idx) => {
|
/* 6️⃣ persist the edits – PUT milestone, diff impacts */
|
||||||
setNewMilestoneMap((prev) => {
|
const saveInlineMilestone = async (m) => {
|
||||||
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) => {
|
|
||||||
const data = newMilestoneMap[m.id];
|
const data = newMilestoneMap[m.id];
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
|
/* --- update the milestone header --- */
|
||||||
const payload = {
|
const payload = {
|
||||||
milestone_type: "Financial",
|
milestone_type : 'Financial',
|
||||||
title: data.title,
|
title : data.title,
|
||||||
description: data.description,
|
description : data.description,
|
||||||
date: data.date,
|
date : toSqlDate(data.date),
|
||||||
career_profile_id: careerProfileId,
|
career_profile_id : careerProfileId,
|
||||||
progress: data.progress,
|
progress : data.progress,
|
||||||
status: data.progress >= 100 ? "completed" : "planned",
|
status : data.progress >= 100 ? 'completed' : 'planned',
|
||||||
new_salary: data.newSalary ? parseFloat(data.newSalary) : null,
|
new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
|
||||||
is_universal: data.isUniversal || 0
|
is_universal : data.isUniversal || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
|
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
|
||||||
method: "PUT",
|
method : 'PUT',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body : JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const saved = await res.json();
|
const saved = await res.json();
|
||||||
|
|
||||||
/* impacts */
|
/* --- figure out what changed ---------------------------------- */
|
||||||
const toDelete = impactsToDeleteMap[m.id] || [];
|
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) {
|
for (const delId of toDelete) {
|
||||||
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
|
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
|
||||||
method: "DELETE"
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- creates / updates --- */
|
||||||
for (const imp of data.impacts) {
|
for (const imp of data.impacts) {
|
||||||
const impPayload = {
|
const impPayload = {
|
||||||
milestone_id: saved.id,
|
milestone_id : saved.id,
|
||||||
impact_type: imp.impact_type,
|
impact_type : imp.impact_type,
|
||||||
direction: imp.direction,
|
direction : imp.direction,
|
||||||
amount: parseFloat(imp.amount) || 0,
|
amount : parseFloat(imp.amount) || 0,
|
||||||
start_date: imp.start_date || null,
|
start_date : toSqlDate(imp.start_date) || null,
|
||||||
end_date: imp.end_date || null
|
end_date : toSqlDate(imp.end_date) || null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (imp.id) {
|
if (imp.id) {
|
||||||
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
||||||
method: "PUT",
|
method : 'PUT',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(impPayload)
|
body : JSON.stringify(impPayload)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await authFetch("/api/premium/milestone-impacts", {
|
await authFetch('/api/premium/milestone-impacts', {
|
||||||
method: "POST",
|
method : 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(impPayload)
|
body : JSON.stringify(impPayload)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- refresh + close --- */
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
setEditingMilestoneId(null);
|
setEditingMilestoneId(null);
|
||||||
onClose(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Failed to save milestone");
|
alert('Failed to save milestone');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* brand‑new milestone helpers (trimmed) */
|
/* ───────────── misc helpers the JSX still calls ───────────── */
|
||||||
const addNewImpactToNewMilestone = () => {
|
|
||||||
setNewMilestoneData((p) => ({
|
/* A) delete one milestone row altogether */
|
||||||
...p,
|
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: [
|
impacts: [
|
||||||
...p.impacts,
|
...prev.impacts,
|
||||||
{
|
{
|
||||||
impact_type: "ONE_TIME",
|
impact_type : 'ONE_TIME',
|
||||||
direction: "subtract",
|
direction : 'subtract',
|
||||||
amount: 0,
|
amount : 0,
|
||||||
start_date: "",
|
start_date : '',
|
||||||
end_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 {
|
try {
|
||||||
const res = await authFetch("/api/premium/milestone", {
|
const res = await authFetch('/api/premium/milestone', {
|
||||||
method: "POST",
|
method : 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body : JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
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) {
|
for (const imp of newMilestoneData.impacts) {
|
||||||
const impPayload = {
|
const impPayload = {
|
||||||
milestone_id: created.id,
|
milestone_id : created.id,
|
||||||
impact_type: imp.impact_type,
|
impact_type : imp.impact_type,
|
||||||
direction: imp.direction,
|
direction : imp.direction,
|
||||||
amount: parseFloat(imp.amount) || 0,
|
amount : parseFloat(imp.amount) || 0,
|
||||||
start_date: imp.start_date || null,
|
start_date : toSqlDate(imp.start_date) || null,
|
||||||
end_date: imp.end_date || null
|
end_date : toSqlDate(imp.end_date) || null
|
||||||
};
|
};
|
||||||
await authFetch("/api/premium/milestone-impacts", {
|
await authFetch('/api/premium/milestone-impacts', {
|
||||||
method: "POST",
|
method : 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(impPayload)
|
body : JSON.stringify(impPayload)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await fetchMilestones();
|
|
||||||
|
await fetchMilestones(); // refresh list
|
||||||
|
setAddingNewMilestone(false); // collapse the new-mile form
|
||||||
onClose(true);
|
onClose(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Failed to save milestone");
|
alert('Failed to save milestone');
|
||||||
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ────────────────────────────────
|
/* ────────────────────────────────
|
||||||
Render
|
Render
|
||||||
@ -296,6 +351,12 @@ export default function MilestoneEditModal({
|
|||||||
<Button onClick={() => handleEditMilestoneInline(m)}>
|
<Button onClick={() => handleEditMilestoneInline(m)}>
|
||||||
{hasEditOpen ? "Cancel" : "Edit"}
|
{hasEditOpen ? "Cancel" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ marginLeft: "0.5rem", color: "black", backgroundColor: "red" }}
|
||||||
|
onClick={() => deleteMilestone(m)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p>{m.description}</p>
|
<p>{m.description}</p>
|
||||||
<p>
|
<p>
|
||||||
@ -343,7 +404,7 @@ export default function MilestoneEditModal({
|
|||||||
/>
|
/>
|
||||||
{/* impacts */}
|
{/* impacts */}
|
||||||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
<h6>Impacts</h6>
|
<h6>Financial Impacts</h6>
|
||||||
{data.impacts?.map((imp, idx) => (
|
{data.impacts?.map((imp, idx) => (
|
||||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||||||
<label>Type:</label>
|
<label>Type:</label>
|
||||||
@ -351,6 +412,7 @@ export default function MilestoneEditModal({
|
|||||||
value={imp.impact_type}
|
value={imp.impact_type}
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)}
|
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="ONE_TIME">One-Time</option>
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
@ -389,7 +451,7 @@ export default function MilestoneEditModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button onClick={() => addInlineImpact(m.id)}>+ Impact</Button>
|
<Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -441,6 +503,7 @@ export default function MilestoneEditModal({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<option value="salary">Salary (annual)</option>
|
||||||
<option value="ONE_TIME">One-Time</option>
|
<option value="ONE_TIME">One-Time</option>
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
@ -516,7 +579,7 @@ export default function MilestoneEditModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button onClick={addNewImpactToNewMilestone}>+ Impact</Button>
|
<Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,7 +30,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://dev1.aptivaai.com/api/signin', {
|
const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
@ -105,7 +105,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
<p className="mt-4 text-center text-sm text-gray-600">
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
Don’t have an account?{' '}
|
Don’t have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
to="/signup"
|
//to="/signup" // <- here
|
||||||
className="font-medium text-blue-600 hover:text-blue-500"
|
className="font-medium text-blue-600 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
Sign Up
|
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) => {
|
milestoneImpacts.forEach((rawImpact) => {
|
||||||
/* --- safety / coercion ------------------------------------------------ */
|
/* ---------- 1. Normalise ---------- */
|
||||||
const amount = Number(rawImpact.amount) || 0; // ← always a number
|
const amount = Number(rawImpact.amount) || 0;
|
||||||
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // 'ONE_TIME' | 'MONTHLY'
|
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME
|
||||||
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // 'add' | 'subtract'
|
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract
|
||||||
|
|
||||||
/* --- date math -------------------------------------------------------- */
|
/* ---------- 2. Work out timing ---------- */
|
||||||
const startDateClamped = moment(rawImpact.start_date).startOf('month');
|
const startDate = moment(rawImpact.start_date).startOf('month');
|
||||||
let startOffset = startDateClamped.diff(scenarioStartClamped, 'months');
|
const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null;
|
||||||
if (startOffset < 0) startOffset = 0;
|
|
||||||
|
|
||||||
let endOffset = Infinity;
|
const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months'));
|
||||||
if (rawImpact.end_date && rawImpact.end_date.trim() !== '') {
|
const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity;
|
||||||
const endDateClamped = moment(rawImpact.end_date).startOf('month');
|
|
||||||
endOffset = endDateClamped.diff(scenarioStartClamped, 'months');
|
|
||||||
if (endOffset < 0) endOffset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- apply impact ----------------------------------------------------- */
|
const isActiveThisMonth =
|
||||||
const applyAmount = (dir) =>
|
(type === 'ONE_TIME' && monthIndex === startOffset) ||
|
||||||
dir === 'add' ? (baseMonthlyIncome += amount) : (extraImpactsThisMonth += amount);
|
(type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset);
|
||||||
|
|
||||||
if (type === 'ONE_TIME') {
|
if (!isActiveThisMonth) return; // skip to next impact
|
||||||
if (monthIndex === startOffset) applyAmount(direction);
|
|
||||||
|
/* ---------- 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 {
|
} else {
|
||||||
// MONTHLY (or anything else) – apply for the whole span
|
// MONTHLY or ONE_TIME expenses / windfalls
|
||||||
if (monthIndex >= startOffset && monthIndex <= endOffset) applyAmount(direction);
|
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
|
* 7.4 CALCULATE TAXES
|
||||||
|
Loading…
Reference in New Issue
Block a user