Edit Goals added, Milestones/tasks drawer added.
This commit is contained in:
parent
9d00ac337c
commit
5eb0750dfb
@ -259,6 +259,19 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// server3.js (add near the other career-profile routes)
|
||||||
|
app.put('/api/premium/career-profile/:id/goals', authenticatePremiumUser, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { career_goals } = req.body;
|
||||||
|
|
||||||
|
// simple ownership check
|
||||||
|
const [rows] = await pool.query('SELECT user_id FROM career_profiles WHERE id=?', [id]);
|
||||||
|
if (!rows[0] || rows[0].user_id !== req.id) {
|
||||||
|
return res.status(403).json({ error: 'Not your profile.' });
|
||||||
|
}
|
||||||
|
await pool.query('UPDATE career_profiles SET career_goals=? WHERE id=?', [career_goals, id]);
|
||||||
|
res.json({ career_goals });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE a career profile (scenario) by ID
|
// DELETE a career profile (scenario) by ID
|
||||||
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||||||
@ -2473,6 +2486,40 @@ app.delete('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { career_path_id, status = 'all' } = req.query;
|
||||||
|
const args = [req.id]; // << first placeholder = user_id
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
t.id, t.milestone_id, t.title, t.description,
|
||||||
|
t.due_date, t.status,
|
||||||
|
t.created_at, t.updated_at,
|
||||||
|
|
||||||
|
m.title AS milestone_title,
|
||||||
|
m.date AS milestone_date,
|
||||||
|
cp.id AS career_path_id,
|
||||||
|
cp.career_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN milestones m ON m.id = t.milestone_id
|
||||||
|
JOIN career_paths cp ON cp.id = m.career_path_id
|
||||||
|
WHERE cp.user_id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }
|
||||||
|
if (status !== 'all') { sql += ' AND t.status = ?'; args.push(status); }
|
||||||
|
|
||||||
|
sql += ' ORDER BY COALESCE(t.due_date, m.date) ASC';
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, args);
|
||||||
|
return res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tasks:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch tasks.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
MILESTONE IMPACTS ENDPOINTS
|
MILESTONE IMPACTS ENDPOINTS
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
@ -2664,6 +2711,10 @@ app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
O*NET KSA DATA
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
let onetKsaData = []; // entire array from ksa_data.json
|
let onetKsaData = []; // entire array from ksa_data.json
|
||||||
let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
|
let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
|
||||||
|
|
||||||
|
@ -73,6 +73,8 @@ export default function CareerCoach({
|
|||||||
userProfile,
|
userProfile,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
scenarioRow,
|
scenarioRow,
|
||||||
|
setScenarioRow,
|
||||||
|
careerProfileId,
|
||||||
collegeProfile,
|
collegeProfile,
|
||||||
onMilestonesCreated,
|
onMilestonesCreated,
|
||||||
onAiRiskFetched
|
onAiRiskFetched
|
||||||
@ -83,6 +85,9 @@ export default function CareerCoach({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [aiRisk, setAiRisk] = useState(null);
|
const [aiRisk, setAiRisk] = useState(null);
|
||||||
const chatRef = useRef(null);
|
const chatRef = useRef(null);
|
||||||
|
const [showGoals , setShowGoals ] = useState(false);
|
||||||
|
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
|
||||||
|
const [saving , setSaving ] = useState(false);
|
||||||
|
|
||||||
/* -------------- scroll --------------- */
|
/* -------------- scroll --------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -248,7 +253,7 @@ I'm here to support you with personalized coaching. What would you like to focus
|
|||||||
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
||||||
|
|
||||||
{/* Quick-action bar */}
|
{/* Quick-action bar */}
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => triggerQuickAction("networking")}
|
onClick={() => triggerQuickAction("networking")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -270,8 +275,22 @@ I'm here to support you with personalized coaching. What would you like to focus
|
|||||||
>
|
>
|
||||||
Interview Help
|
Interview Help
|
||||||
</button>
|
</button>
|
||||||
|
{/* pushes Edit Goals to the far right */}
|
||||||
|
<div className="flex-grow"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// always load the latest goals before showing the modal
|
||||||
|
setDraftGoals(scenarioRow?.career_goals || "");
|
||||||
|
setShowGoals(true);
|
||||||
|
}}
|
||||||
|
className="border border-gray-300 bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-300"
|
||||||
|
>
|
||||||
|
Edit Goals
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Chat area */}
|
{/* Chat area */}
|
||||||
<div
|
<div
|
||||||
ref={chatRef}
|
ref={chatRef}
|
||||||
@ -322,6 +341,53 @@ I'm here to support you with personalized coaching. What would you like to focus
|
|||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{showGoals && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow">
|
||||||
|
<h3 className="text-xl font-semibold mb-3">Edit Your Career Goals</h3>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={draftGoals}
|
||||||
|
onChange={(e) => setDraftGoals(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
placeholder="Describe your short- and long-term goals…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded border"
|
||||||
|
onClick={() => setShowGoals(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={async () => {
|
||||||
|
setSaving(true);
|
||||||
|
await authFetch(
|
||||||
|
`/api/premium/career-profile/${careerProfileId}/goals`,
|
||||||
|
{
|
||||||
|
method : 'PUT',
|
||||||
|
headers: { 'Content-Type':'application/json' },
|
||||||
|
body : JSON.stringify({ career_goals: draftGoals })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// lift new goals into parent state so Jess sees them
|
||||||
|
setScenarioRow((p) => ({ ...p, career_goals: draftGoals }));
|
||||||
|
setSaving(false);
|
||||||
|
setShowGoals(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import {
|
|||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
import MilestonePanel from './MilestonePanel.js';
|
import MilestonePanel from './MilestonePanel.js';
|
||||||
|
import MilestoneDrawer from './MilestoneDrawer.js';
|
||||||
import MilestoneEditModal from './MilestoneEditModal.js';
|
import MilestoneEditModal from './MilestoneEditModal.js';
|
||||||
import buildChartMarkers from '../utils/buildChartMarkers.js';
|
import buildChartMarkers from '../utils/buildChartMarkers.js';
|
||||||
import getMissingFields from '../utils/getMissingFields.js';
|
import getMissingFields from '../utils/getMissingFields.js';
|
||||||
@ -353,6 +354,9 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||||
const [milestoneForModal, setMilestoneForModal] = useState(null);
|
const [milestoneForModal, setMilestoneForModal] = useState(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [focusMid , setFocusMid ] = useState(null);
|
||||||
|
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||||
@ -558,18 +562,18 @@ useEffect(() => {
|
|||||||
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
|
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const guard = modalGuard.current;
|
|
||||||
if (!dataReady || guard.checked) return;
|
|
||||||
|
|
||||||
/* honour skip flag (one-time after onboarding / sessionStorage) */
|
if (!dataReady || !careerProfileId) return;
|
||||||
if (guard.skip) {
|
|
||||||
guard.skip = false; // consume it
|
// one key per career profile
|
||||||
guard.checked = true;
|
const key = `modalChecked:${careerProfileId}`;
|
||||||
return;
|
|
||||||
}
|
// already checked in this browser session?
|
||||||
|
if (sessionStorage.getItem(key) === '1') return;
|
||||||
|
|
||||||
const status = (scenarioRow.college_enrollment_status || '').toLowerCase();
|
const status = (scenarioRow.college_enrollment_status || '').toLowerCase();
|
||||||
const requireCollege = ['currently_enrolled', 'prospective_student', 'deferred'].includes(status);
|
const requireCollege = ['currently_enrolled','prospective_student','deferred']
|
||||||
|
.includes(status);
|
||||||
|
|
||||||
const missing = getMissingFields(
|
const missing = getMissingFields(
|
||||||
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
|
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
|
||||||
@ -577,8 +581,11 @@ useEffect(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (missing.length) setShowEditModal(true);
|
if (missing.length) setShowEditModal(true);
|
||||||
guard.checked = true; // ensure we don’t rerun
|
|
||||||
}, [dataReady, scenarioRow, financialProfile, collegeProfile]);
|
sessionStorage.setItem(key, '1'); // remember for this tab
|
||||||
|
|
||||||
|
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1199,6 +1206,8 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
userProfile={userProfile}
|
userProfile={userProfile}
|
||||||
financialProfile={financialProfile}
|
financialProfile={financialProfile}
|
||||||
scenarioRow={scenarioRow}
|
scenarioRow={scenarioRow}
|
||||||
|
setScenarioRow={setScenarioRow}
|
||||||
|
careerProfileId={careerProfileId}
|
||||||
collegeProfile={collegeProfile}
|
collegeProfile={collegeProfile}
|
||||||
onMilestonesCreated={() => {
|
onMilestonesCreated={() => {
|
||||||
/* refresh or reload logic here */
|
/* refresh or reload logic here */
|
||||||
@ -1326,21 +1335,13 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
</div>*/}
|
</div>*/}
|
||||||
|
|
||||||
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
|
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
|
||||||
<div className="bg-white p-4 rounded shadow">
|
<div className="bg-white p-4 rounded shadow">
|
||||||
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
||||||
|
|
||||||
|
|
||||||
{projectionData.length ? (
|
{projectionData.length ? (
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div>
|
||||||
{/* Milestone list / editor */}
|
{/* Chart – now full width */}
|
||||||
<MilestonePanel
|
|
||||||
className="md:w-56"
|
|
||||||
groups={milestoneGroups}
|
|
||||||
onEdit={onEditMilestone} /* <-- use your existing handler */
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Chart */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div style={{ height: 360, width: '100%' }}>
|
<div style={{ height: 360, width: '100%' }}>
|
||||||
<Line
|
<Line
|
||||||
ref={chartRef}
|
ref={chartRef}
|
||||||
@ -1361,8 +1362,7 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => chartRef.current?.resetZoom()}
|
||||||
onClick={() => chartRef.current?.resetZoom()}
|
|
||||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
Reset Zoom
|
Reset Zoom
|
||||||
@ -1376,11 +1376,24 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Milestones – stacked list under chart */}
|
||||||
|
<div className="mt-4 bg-white p-4 rounded shadow">
|
||||||
|
<h4 className="text-lg font-semibold mb-2">Milestones</h4>
|
||||||
|
<MilestonePanel
|
||||||
|
groups={milestoneGroups}
|
||||||
|
onEdit={onEditMilestone}
|
||||||
|
onSelect={(m) => {
|
||||||
|
setDrawerMilestone(m);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 6) Simulation length + Edit scenario */}
|
{/* 6) Simulation length + Edit scenario */}
|
||||||
<div className="mt-4 space-x-2">
|
<div className="mt-4 space-x-2">
|
||||||
@ -1474,6 +1487,17 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MilestoneDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
milestone={drawerMilestone}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
onTaskToggle={(id, newStatus) => {
|
||||||
|
// optimistic local patch or just refetch
|
||||||
|
fetchMilestones(); // simplest: keep server source of truth
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 7) AI Next Steps */}
|
{/* 7) AI Next Steps */}
|
||||||
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
||||||
|
57
src/components/EditableCareerGoals.js
Normal file
57
src/components/EditableCareerGoals.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// src/components/EditableCareerGoals.js
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
import { Pencil, Save } from 'lucide-react';
|
||||||
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
|
||||||
|
export default function EditableCareerGoals({ initialGoals='', careerProfileId, onSaved }) {
|
||||||
|
const [editing , setEditing ] = useState(false);
|
||||||
|
const [draftText, setDraftText] = useState(initialGoals);
|
||||||
|
const [saving , setSaving ] = useState(false);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
const res = await authFetch(`/api/premium/career-profile/${careerProfileId}/goals`, {
|
||||||
|
method : 'PUT',
|
||||||
|
headers: { 'Content-Type':'application/json' },
|
||||||
|
body : JSON.stringify({ career_goals: draftText })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onSaved(draftText);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-4 rounded shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Your Career Goals</h3>
|
||||||
|
{!editing && (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setEditing(true)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={draftText}
|
||||||
|
onChange={e => setDraftText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full border rounded p-2 mt-2"
|
||||||
|
placeholder="Describe your short- and long-term goals…"
|
||||||
|
/>
|
||||||
|
<Button onClick={save} disabled={saving} className="mt-2">
|
||||||
|
{saving ? 'Saving…' : <><Save className="w-4 h-4 mr-1" /> Save</>}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-gray-700">
|
||||||
|
{initialGoals || <span className="italic text-gray-400">No goals entered yet.</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
144
src/components/MilestoneDrawer.js
Normal file
144
src/components/MilestoneDrawer.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
import { Card, CardContent } from './ui/card.js';
|
||||||
|
import { ChevronLeft, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { flattenTasks } from '../utils/taskHelpers.js';
|
||||||
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
|
||||||
|
/* simple status → color map */
|
||||||
|
const pillStyle = {
|
||||||
|
completed : 'bg-green-100 text-green-800',
|
||||||
|
in_progress : 'bg-blue-100 text-blue-800',
|
||||||
|
not_started : 'bg-gray-100 text-gray-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MilestoneDrawer({
|
||||||
|
milestone, // ← pass a single milestone object
|
||||||
|
milestones = [], // still needed to compute progress %
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onTaskToggle = () => {}
|
||||||
|
}) {
|
||||||
|
|
||||||
|
/* gather tasks progress for this milestone */
|
||||||
|
const [tasks, setTasks] = useState(
|
||||||
|
milestone ? flattenTasks([milestone]) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// refresh local copy whenever the user selects a different milestone
|
||||||
|
useEffect(() => {
|
||||||
|
setTasks(milestone ? flattenTasks([milestone]) : []);
|
||||||
|
}, [milestone]);
|
||||||
|
|
||||||
|
const done = tasks.filter(t => t.status === 'completed').length;
|
||||||
|
const prog = tasks.length ? Math.round(100 * done / tasks.length) : 0;
|
||||||
|
|
||||||
|
if (!open || !milestone) return null;
|
||||||
|
|
||||||
|
async function toggle(t) {
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
not_started : 'in_progress',
|
||||||
|
in_progress : 'completed',
|
||||||
|
completed : 'not_started' // undo
|
||||||
|
};
|
||||||
|
|
||||||
|
const newStatus = next[t.status] || 'not_started';
|
||||||
|
|
||||||
|
|
||||||
|
/* 1️⃣ optimistic local update */
|
||||||
|
setTasks(prev =>
|
||||||
|
prev.map(x =>
|
||||||
|
x.id === t.id ? { ...x, status: newStatus } : x
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* 2️⃣ inform parent so progress bars refresh elsewhere */
|
||||||
|
onTaskToggle(t.id, newStatus);
|
||||||
|
|
||||||
|
await authFetch(`/api/premium/tasks/${t.id}`, {
|
||||||
|
method : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body : JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = {
|
||||||
|
not_started : 'Not started',
|
||||||
|
in_progress : 'In progress',
|
||||||
|
completed : 'Completed'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-40 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-2 border-b flex items-center gap-3">
|
||||||
|
<Button size="icon" variant="ghost" onClick={onClose}>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{milestone.title}</p>
|
||||||
|
{milestone.date && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{format(new Date(milestone.date), 'PP')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Card className="flex-1 overflow-y-auto rounded-none">
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div>
|
||||||
|
<progress value={prog} max={100} className="w-full h-2" />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{prog}% complete</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
{tasks.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="border p-3 rounded-lg flex items-start justify-between"
|
||||||
|
>
|
||||||
|
<div className="pr-2">
|
||||||
|
<p className="font-medium break-words">{t.title}</p>
|
||||||
|
{t.due_date && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{format(new Date(t.due_date), 'PP')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Toggle task status"
|
||||||
|
onClick={() => toggle(t)}
|
||||||
|
>
|
||||||
|
{t.status === 'completed'
|
||||||
|
? <Check className="w-5 h-5 text-green-600" />
|
||||||
|
: (
|
||||||
|
<span
|
||||||
|
className={`shrink-0 inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold ${pillStyle[t.status]}`} style={{ whiteSpace: 'nowrap' }} /* never wrap */
|
||||||
|
>
|
||||||
|
{statusLabel[t.status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!tasks.length && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
No tasks have been added to this milestone yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -3,9 +3,9 @@ import { Button } from './ui/button.js';
|
|||||||
|
|
||||||
|
|
||||||
/* MilestonePanel.jsx ---------------------------------- */
|
/* MilestonePanel.jsx ---------------------------------- */
|
||||||
export default function MilestonePanel({ groups, onEdit }) {
|
export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
||||||
return (
|
return (
|
||||||
<aside className="w-full md:w-56 pr-4">
|
<aside className="w-full pr-4">
|
||||||
{groups.map(g => (
|
{groups.map(g => (
|
||||||
<details key={g.month} className="mb-2">
|
<details key={g.month} className="mb-2">
|
||||||
<summary className="cursor-pointer font-semibold">
|
<summary className="cursor-pointer font-semibold">
|
||||||
@ -14,14 +14,25 @@ export default function MilestonePanel({ groups, onEdit }) {
|
|||||||
|
|
||||||
<ul className="mt-1 space-y-2">
|
<ul className="mt-1 space-y-2">
|
||||||
{g.items.map(m => (
|
{g.items.map(m => (
|
||||||
<li key={m.id} className="flex items-start gap-1 text-sm">
|
<li
|
||||||
<span className="flex-1">{m.title}</span>
|
key={m.id}
|
||||||
<Button
|
className="flex items-start justify-between text-sm group cursor-pointer"
|
||||||
onClick={() => onEdit(m)}
|
onClick={() => onSelect?.(m)}
|
||||||
size="icon"
|
|
||||||
className="bg-transparent hover:bg-muted p-1 rounded-sm"
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<span>{m.title}</span>
|
||||||
|
|
||||||
|
{/* edit pencil – invisible until row hover */}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="opacity-0 group-hover:opacity-75 transition-opacity"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation(); // don’t also open drawer
|
||||||
|
onEdit(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 text-gray-500 hover:text-gray-700" />
|
||||||
|
<span className="sr-only">Edit milestone</span>
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
13
src/components/ui/badge.js
Normal file
13
src/components/ui/badge.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
export function Badge({ className = '', children }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold
|
||||||
|
bg-gray-200 text-gray-700 ${className}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
@ -8,3 +8,13 @@ export const Card = ({ className, ...props }) => (
|
|||||||
export const CardContent = ({ className, ...props }) => (
|
export const CardContent = ({ className, ...props }) => (
|
||||||
<div className={cn("p-4", className)} {...props} />
|
<div className={cn("p-4", className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CardHeader = ({ className, ...props }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 border-b flex items-center justify-between",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
21
src/utils/infoIcon.js
Normal file
21
src/utils/infoIcon.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <InfoTooltip message="This is a helpful tooltip" />
|
||||||
|
*
|
||||||
|
* Re-usable tiny "i" badge that shows its `message` prop in a native
|
||||||
|
* browser tooltip (`title=`). Sized for use inline with headings or
|
||||||
|
* labels. Uses Tailwind classes only—no external libs required.
|
||||||
|
*/
|
||||||
|
export default function InfoTooltip({ message = "" }) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-[10px] font-semibold cursor-help"
|
||||||
|
title={message}
|
||||||
|
>
|
||||||
|
i
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
19
src/utils/taskHelpers.js
Normal file
19
src/utils/taskHelpers.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function flattenTasks(milestones) {
|
||||||
|
return milestones.flatMap(m =>
|
||||||
|
(m.tasks || []).map(t => ({
|
||||||
|
...t,
|
||||||
|
milestone_id : m.id,
|
||||||
|
milestoneTitle : m.title,
|
||||||
|
milestoneDate : m.date,
|
||||||
|
career_path_id : m.career_path_id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* recompute milestone.progress client-side (optimistic) */
|
||||||
|
export function recomputeProgress(tasksForMilestone) {
|
||||||
|
const total = tasksForMilestone.length;
|
||||||
|
if (!total) return 0;
|
||||||
|
const done = tasksForMilestone.filter(t => t.status === 'completed').length;
|
||||||
|
return Math.round((done / total) * 100);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user