New components for AI ops/REtirement planning
This commit is contained in:
parent
fe2ec2d3c1
commit
8c78540e6c
@ -28,7 +28,7 @@ import FinancialProfileForm from './components/FinancialProfileForm.js';
|
|||||||
import CareerRoadmap from './components/CareerRoadmap.js';
|
import CareerRoadmap from './components/CareerRoadmap.js';
|
||||||
import Paywall from './components/Paywall.js';
|
import Paywall from './components/Paywall.js';
|
||||||
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
||||||
import MultiScenarioView from './components/MultiScenarioView.js';
|
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -53,7 +53,7 @@ function App() {
|
|||||||
'/career-roadmap',
|
'/career-roadmap',
|
||||||
'/paywall',
|
'/paywall',
|
||||||
'/financial-profile',
|
'/financial-profile',
|
||||||
'/multi-scenario',
|
'/retirement-planner',
|
||||||
'/premium-onboarding',
|
'/premium-onboarding',
|
||||||
'/enhancing',
|
'/enhancing',
|
||||||
'/retirement',
|
'/retirement',
|
||||||
@ -446,10 +446,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/multi-scenario"
|
path="/retirement-planner"
|
||||||
element={
|
element={
|
||||||
<PremiumRoute user={user}>
|
<PremiumRoute user={user}>
|
||||||
<MultiScenarioView />
|
<RetirementPlanner />
|
||||||
</PremiumRoute>
|
</PremiumRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -28,6 +28,7 @@ import { simulateFinancialProjection } from '../utils/FinancialProjectionService
|
|||||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||||
import { getFullStateName } from '../utils/stateUtils.js';
|
import { getFullStateName } from '../utils/stateUtils.js';
|
||||||
import CareerCoach from "./CareerCoach.js";
|
import CareerCoach from "./CareerCoach.js";
|
||||||
|
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
|
244
src/components/RetirementChatBar.js
Normal file
244
src/components/RetirementChatBar.js
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
// src/components/RetirementChatBar.js
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { cn } from '../utils/cn.js';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────
|
||||||
|
1. Static “OPS cheat-sheet” card
|
||||||
|
- sent exactly once, identified by the sentinel below
|
||||||
|
- keep the FULL text here (truncated for brevity)
|
||||||
|
────────────────────────────────────────────────────────────*/
|
||||||
|
const OPS_SENTINEL = 'APTIVA OPS CHEAT-SHEET'; // do NOT change
|
||||||
|
const STATIC_SYSTEM_CARD = `
|
||||||
|
${OPS_SENTINEL}
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
🛠 Allowed OPS JSON
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
• CREATE / UPDATE / DELETE milestones, tasks, impacts
|
||||||
|
• Always wrap ops payload in a \`\`\`ops ... \`\`\` fence
|
||||||
|
• One short confirmation line → then the fence → nothing after
|
||||||
|
(…include your complete text here…)
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/* Other global helpers */
|
||||||
|
const CTX_SENTINEL = '[DETAILED USER PROFILE]';
|
||||||
|
const MAX_TURNS = 6;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ helpers */
|
||||||
|
function buildStatusSituationCard(userProfile = {}, scenarioRow = {}) {
|
||||||
|
const careerName = scenarioRow.career_name || 'your chosen career';
|
||||||
|
const status = scenarioRow.status || 'planned';
|
||||||
|
const situation = userProfile.career_situation || 'exploring';
|
||||||
|
|
||||||
|
return `[STATUS]
|
||||||
|
Currently **${status}**, you are ${situation} ${careerName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMilestoneGridCard(grid = []) {
|
||||||
|
if (!grid.length) return '[CURRENT MILESTONES]\n- none -';
|
||||||
|
const rows = grid.map(r => `${r.id}|${r.title.trim()}|${r.date}`).join('\n');
|
||||||
|
return `[CURRENT MILESTONES]
|
||||||
|
(id | title | date)
|
||||||
|
${rows}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build the outbound message array each turn */
|
||||||
|
function buildMessages({
|
||||||
|
chatHistory = [],
|
||||||
|
userProfile = {},
|
||||||
|
scenarioRow = {},
|
||||||
|
milestoneGrid = [],
|
||||||
|
largeSummaryCard = '',
|
||||||
|
forceContext = false
|
||||||
|
}) {
|
||||||
|
const safeHistory = chatHistory.map(m =>
|
||||||
|
m.content != null ? m : { ...m, content: m.text ?? '' }
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Send static card once per convo */
|
||||||
|
const needOpsCard = !safeHistory.some(
|
||||||
|
m => m.role === 'system' && m.content?.includes(OPS_SENTINEL)
|
||||||
|
);
|
||||||
|
const staticCards = needOpsCard
|
||||||
|
? [{ role: 'system', content: STATIC_SYSTEM_CARD }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/* Send big context when missing or forced */
|
||||||
|
const needCtxCard =
|
||||||
|
forceContext ||
|
||||||
|
!chatHistory.some(
|
||||||
|
m => m.role === 'system' && m.content?.startsWith(CTX_SENTINEL)
|
||||||
|
);
|
||||||
|
const contextCards =
|
||||||
|
needCtxCard && largeSummaryCard
|
||||||
|
? [{ role: 'system', content: `${CTX_SENTINEL}\n${largeSummaryCard}` }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/* Small per-turn helpers */
|
||||||
|
const smallCards = [
|
||||||
|
{ role: 'system', content: buildStatusSituationCard(userProfile, scenarioRow) },
|
||||||
|
{ role: 'system', content: buildMilestoneGridCard(milestoneGrid) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const recent = safeHistory.slice(-MAX_TURNS);
|
||||||
|
return [...staticCards, ...contextCards, ...smallCards, ...recent]
|
||||||
|
.map(m => ({ ...m, content: m.content ?? '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ component */
|
||||||
|
export default function RetirementChatBar({
|
||||||
|
scenario = null, // full scenario row
|
||||||
|
userProfile = {}, // caller supplies or {}
|
||||||
|
financialProfile = {},
|
||||||
|
collegeProfile = {},
|
||||||
|
milestoneGrid = [],
|
||||||
|
onScenarioPatch,
|
||||||
|
className = ''
|
||||||
|
}) {
|
||||||
|
const [chatHistory, setChatHistory] = useState([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [forceCtx, setForceCtx] = useState(false);
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
|
||||||
|
/* wipe chat on scenario change */
|
||||||
|
useEffect(() => setChatHistory([]), [scenario?.id]);
|
||||||
|
|
||||||
|
/* autoscroll */
|
||||||
|
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
|
||||||
|
[chatHistory]);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* SEND PROMPT — guarded diff + safe input-handling */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
async function sendPrompt() {
|
||||||
|
const prompt = input.trim();
|
||||||
|
if (!prompt || !scenario?.id) return;
|
||||||
|
|
||||||
|
/* ① optimistic UI – show the user bubble immediately */
|
||||||
|
const userMsg = { role: 'user', content: prompt };
|
||||||
|
setChatHistory(h => [...h, userMsg]);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* ② rebuild outbound history (adds system helper cards) */
|
||||||
|
const messagesToSend = buildMessages({
|
||||||
|
chatHistory : [...chatHistory, userMsg],
|
||||||
|
userProfile,
|
||||||
|
scenarioRow : scenario,
|
||||||
|
milestoneGrid,
|
||||||
|
largeSummaryCard: window.CACHED_SUMMARY || '',
|
||||||
|
forceContext : forceCtx
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ③ POST to the retirement endpoint */
|
||||||
|
const res = await authFetch('/api/premium/retirement/aichat', {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body : JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
scenario_id : scenario.id, // ← keep it minimal
|
||||||
|
chatHistory : messagesToSend // ← backend needs this to find userMsg
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const assistantReply = data.reply || '(no response)';
|
||||||
|
setChatHistory(h => [...h, { role: 'assistant', content: assistantReply }]);
|
||||||
|
|
||||||
|
/* ④ if we got a real patch, build + forward the diff array */
|
||||||
|
if (data.scenarioPatch && onScenarioPatch) {
|
||||||
|
onScenarioPatch(scenario.id, data.scenarioPatch); // ✅ id + patch
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setChatHistory(h => [
|
||||||
|
...h,
|
||||||
|
{ role: 'assistant', content: '⚠️ Server error.' }
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setForceCtx(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* enter-to-send */
|
||||||
|
function handleKeyUp(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------- render ----------------------- */
|
||||||
|
const scenarioLabel = scenario
|
||||||
|
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario')
|
||||||
|
: 'Select a scenario';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col shrink-0 w-full md:w-[360px] border-l bg-white',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{scenarioLabel}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!scenario}
|
||||||
|
onClick={() => setForceCtx(true)}
|
||||||
|
title="Force refresh context for next turn"
|
||||||
|
>
|
||||||
|
🔄 Refresh context
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* chat log */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm">
|
||||||
|
{chatHistory.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'max-w-[90%] rounded px-3 py-2 whitespace-pre-wrap',
|
||||||
|
m.role === 'user'
|
||||||
|
? 'ml-auto bg-blue-600 text-white'
|
||||||
|
: 'mr-auto bg-gray-100 text-gray-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* composer */}
|
||||||
|
<form
|
||||||
|
onSubmit={e => { e.preventDefault(); sendPrompt(); }}
|
||||||
|
className="p-3 border-t flex gap-2"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
disabled={!scenario}
|
||||||
|
placeholder={scenario
|
||||||
|
? 'Ask about this scenario…'
|
||||||
|
: 'Click a scenario card first'}
|
||||||
|
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!scenario || loading || !input.trim()}>
|
||||||
|
{loading ? '…' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
@ -16,7 +16,7 @@ function RetirementLanding() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
|
<Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
|
||||||
<Button onClick={() => navigate('/multi-scenario')}>Set Retirement Milestones and get AI help with planning</Button>
|
<Button onClick={() => navigate('/retirement-planner')}>Set Retirement Milestones and get AI help with planning</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,14 +1,29 @@
|
|||||||
// src/components/MultiScenarioView.js
|
// src/components/RetirementPlanner.js
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
import ScenarioContainer from './ScenarioContainer.js';
|
import ScenarioContainer from './ScenarioContainer.js';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
|
import RetirementChatBar from './RetirementChatBar.js';
|
||||||
|
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
|
||||||
|
|
||||||
export default function MultiScenarioView() {
|
export default function RetirementPlanner() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [financialProfile, setFinancialProfile] = useState(null);
|
const [financialProfile, setFinancialProfile] = useState(null);
|
||||||
const [scenarios, setScenarios] = useState([]);
|
const [scenarios, setScenarios] = useState([]);
|
||||||
|
const [diff , setDiff ] = useState(null);
|
||||||
|
const [chatId, setChatId] = useState(null);
|
||||||
|
const [selectedScenario, setSelectedScenario] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
|
const applyPatch = (id, patch) => {
|
||||||
|
setScenarios(prev => {
|
||||||
|
const base = prev.find(s => s.id === id);
|
||||||
|
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
|
||||||
|
setDiff({ base, patch });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadScenariosAndFinancial();
|
loadScenariosAndFinancial();
|
||||||
@ -29,7 +44,7 @@ export default function MultiScenarioView() {
|
|||||||
setFinancialProfile(finData);
|
setFinancialProfile(finData);
|
||||||
setScenarios(scenData.careerProfiles || []);
|
setScenarios(scenData.careerProfiles || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('MultiScenarioView =>', err);
|
console.error('RetirementPlanner =>', err);
|
||||||
setError(err.message || 'Failed to load');
|
setError(err.message || 'Failed to load');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -290,28 +305,51 @@ export default function MultiScenarioView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p>Loading scenarios...</p>;
|
|
||||||
if (error) return <p style={{ color: 'red' }}>{error}</p>;
|
|
||||||
|
|
||||||
const visible = scenarios.slice(0, 2);
|
const visible = scenarios.slice(0, 2);
|
||||||
|
|
||||||
return (
|
if (loading) return <p>Loading scenarios…</p>;
|
||||||
<div style={{ margin: '1rem' }}>
|
if (error) return <p className="text-red-600">{error}</p>;
|
||||||
<Button onClick={handleAddScenario} style={{ marginBottom: '1rem' }}>
|
|
||||||
+ Add Scenario
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
|
|
||||||
{visible.map(sc => (
|
return (
|
||||||
<ScenarioContainer
|
<div className="flex flex-col md:flex-row">
|
||||||
key={sc.id}
|
{/* main column */}
|
||||||
scenario={sc}
|
<div className="flex-1 p-4">
|
||||||
financialProfile={financialProfile}
|
<Button onClick={handleAddScenario} className="mb-4">
|
||||||
onClone={handleCloneScenario}
|
+ Add Scenario
|
||||||
onRemove={handleRemoveScenario}
|
</Button>
|
||||||
/>
|
|
||||||
))}
|
<div className="grid gap-4 auto-cols-max md:grid-cols-[repeat(auto-fill,minmax(420px,1fr))]">
|
||||||
|
{visible.map(sc => (
|
||||||
|
<ScenarioContainer
|
||||||
|
key={sc.id}
|
||||||
|
scenario={sc}
|
||||||
|
financialProfile={financialProfile}
|
||||||
|
onClone={handleCloneScenario}
|
||||||
|
onRemove={handleRemoveScenario}
|
||||||
|
isSelected={selectedScenario?.id === sc.id}
|
||||||
|
onSelect={() => setSelectedScenario(sc)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* right rail */}
|
||||||
|
<RetirementChatBar
|
||||||
|
scenario={selectedScenario}
|
||||||
|
financialProfile={financialProfile}
|
||||||
|
onScenarioPatch={applyPatch}
|
||||||
|
className="w-full md:w-[360px] border-l bg-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* diff drawer */}
|
||||||
|
{!!diff && (
|
||||||
|
<ScenarioDiffDrawer
|
||||||
|
base={diff.base}
|
||||||
|
patch={diff.patch}
|
||||||
|
onClose={() => setDiff(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -17,7 +17,8 @@ export default function ScenarioContainer({
|
|||||||
scenario,
|
scenario,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
onRemove,
|
onRemove,
|
||||||
onClone
|
onClone,
|
||||||
|
onSelect
|
||||||
}) {
|
}) {
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 1) States
|
// 1) States
|
||||||
@ -846,7 +847,11 @@ export default function ScenarioContainer({
|
|||||||
// 10) Render
|
// 10) Render
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
<article
|
||||||
|
onClick={() => onSelect(localScenario.id)}
|
||||||
|
className="w-[420px] border border-gray-300 p-4 rounded cursor-pointer
|
||||||
|
hover:shadow-sm transition-shadow bg-white"
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
style={{ marginBottom: '0.5rem', width: '100%' }}
|
style={{ marginBottom: '0.5rem', width: '100%' }}
|
||||||
value={localScenario?.id || ''}
|
value={localScenario?.id || ''}
|
||||||
@ -1375,6 +1380,6 @@ export default function ScenarioContainer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
36
src/components/ScenarioDiffDrawer.js
Normal file
36
src/components/ScenarioDiffDrawer.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
export default function ScenarioDiffDrawer({ base, patch, onClose }) {
|
||||||
|
if (!patch) return null;
|
||||||
|
const keys = Object.keys(patch);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 max-h-60 overflow-y-auto bg-white border-t border-gray-300 z-50 p-4 shadow-lg">
|
||||||
|
<h4 className="font-semibold mb-3">Scenario changes</h4>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left border-b">
|
||||||
|
<th className="py-1 pr-4">Field</th>
|
||||||
|
<th className="py-1 pr-4">Before</th>
|
||||||
|
<th className="py-1">After</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map(k => (
|
||||||
|
<tr key={k} className="border-b last:border-0">
|
||||||
|
<td className="py-1 pr-4">{k}</td>
|
||||||
|
<td className="py-1 pr-4">{String(base[k] ?? "—")}</td>
|
||||||
|
<td className="py-1">{String(patch[k])}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Button onClick={onClose} className="mt-4">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
49
src/utils/opsEngine.js
Normal file
49
src/utils/opsEngine.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export async function applyOps(opsObj, { req, userId, scenarioId }) {
|
||||||
|
if (!Array.isArray(opsObj?.milestones)) return [];
|
||||||
|
|
||||||
|
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||||
|
const auth = (p, o = {}) => internalFetch(req, `${apiBase}${p}`, o);
|
||||||
|
|
||||||
|
const confirmations = [];
|
||||||
|
|
||||||
|
for (const m of opsObj.milestones) {
|
||||||
|
const op = (m?.op || '').toUpperCase();
|
||||||
|
|
||||||
|
/* ---------- DELETE ---------- */
|
||||||
|
if (op === 'DELETE' && m.id) {
|
||||||
|
const cleanId = m.id.trim();
|
||||||
|
const r = await auth(`/premium/milestones/${cleanId}`, { method: 'DELETE' });
|
||||||
|
if (r.ok) confirmations.push(`Deleted milestone ${cleanId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- UPDATE ---------- */
|
||||||
|
if (op === 'UPDATE' && m.id && m.patch) {
|
||||||
|
const r = await auth(`/premium/milestones/${m.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(m.patch),
|
||||||
|
});
|
||||||
|
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- CREATE ---------- */
|
||||||
|
if (op === 'CREATE' && m.data) {
|
||||||
|
// inject career_profile_id if the bot forgot it
|
||||||
|
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
|
||||||
|
const r = await auth('/premium/milestone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(m.data),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
const newId = Array.isArray(j) ? j[0]?.id : j.id;
|
||||||
|
confirmations.push(`Created milestone ${newId || '(new)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmations;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user