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
|
||||
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
|
||||
------------------------------------------------------------------ */
|
||||
@ -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 allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
|
||||
|
||||
|
@ -73,6 +73,8 @@ export default function CareerCoach({
|
||||
userProfile,
|
||||
financialProfile,
|
||||
scenarioRow,
|
||||
setScenarioRow,
|
||||
careerProfileId,
|
||||
collegeProfile,
|
||||
onMilestonesCreated,
|
||||
onAiRiskFetched
|
||||
@ -83,6 +85,9 @@ export default function CareerCoach({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [aiRisk, setAiRisk] = useState(null);
|
||||
const chatRef = useRef(null);
|
||||
const [showGoals , setShowGoals ] = useState(false);
|
||||
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
|
||||
const [saving , setSaving ] = useState(false);
|
||||
|
||||
/* -------------- scroll --------------- */
|
||||
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>
|
||||
|
||||
{/* Quick-action bar */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => triggerQuickAction("networking")}
|
||||
disabled={loading}
|
||||
@ -269,9 +274,23 @@ I'm here to support you with personalized coaching. What would you like to focus
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white px-3 py-1 rounded"
|
||||
>
|
||||
Interview Help
|
||||
</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>
|
||||
|
||||
|
||||
{/* Chat area */}
|
||||
<div
|
||||
ref={chatRef}
|
||||
@ -322,6 +341,53 @@ I'm here to support you with personalized coaching. What would you like to focus
|
||||
Send
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
} from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import MilestonePanel from './MilestonePanel.js';
|
||||
import MilestoneDrawer from './MilestoneDrawer.js';
|
||||
import MilestoneEditModal from './MilestoneEditModal.js';
|
||||
import buildChartMarkers from '../utils/buildChartMarkers.js';
|
||||
import getMissingFields from '../utils/getMissingFields.js';
|
||||
@ -353,6 +354,9 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||||
const [projectionData, setProjectionData] = useState([]);
|
||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||
const [milestoneForModal, setMilestoneForModal] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [focusMid , setFocusMid ] = useState(null);
|
||||
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
||||
|
||||
// Config
|
||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||
@ -558,18 +562,18 @@ useEffect(() => {
|
||||
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
|
||||
|
||||
useEffect(() => {
|
||||
const guard = modalGuard.current;
|
||||
if (!dataReady || guard.checked) return;
|
||||
|
||||
/* honour skip flag (one-time after onboarding / sessionStorage) */
|
||||
if (guard.skip) {
|
||||
guard.skip = false; // consume it
|
||||
guard.checked = true;
|
||||
return;
|
||||
}
|
||||
if (!dataReady || !careerProfileId) return;
|
||||
|
||||
// one key per career profile
|
||||
const key = `modalChecked:${careerProfileId}`;
|
||||
|
||||
// already checked in this browser session?
|
||||
if (sessionStorage.getItem(key) === '1') return;
|
||||
|
||||
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(
|
||||
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
|
||||
@ -577,8 +581,11 @@ useEffect(() => {
|
||||
);
|
||||
|
||||
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(() => {
|
||||
@ -1199,6 +1206,8 @@ const fetchMilestones = useCallback(async () => {
|
||||
userProfile={userProfile}
|
||||
financialProfile={financialProfile}
|
||||
scenarioRow={scenarioRow}
|
||||
setScenarioRow={setScenarioRow}
|
||||
careerProfileId={careerProfileId}
|
||||
collegeProfile={collegeProfile}
|
||||
onMilestonesCreated={() => {
|
||||
/* refresh or reload logic here */
|
||||
@ -1331,16 +1340,8 @@ const fetchMilestones = useCallback(async () => {
|
||||
|
||||
|
||||
{projectionData.length ? (
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Milestone list / editor */}
|
||||
<MilestonePanel
|
||||
className="md:w-56"
|
||||
groups={milestoneGroups}
|
||||
onEdit={onEditMilestone} /* <-- use your existing handler */
|
||||
/>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
{/* Chart – now full width */}
|
||||
<div style={{ height: 360, width: '100%' }}>
|
||||
<Line
|
||||
ref={chartRef}
|
||||
@ -1361,8 +1362,7 @@ const fetchMilestones = useCallback(async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => chartRef.current?.resetZoom()}
|
||||
<Button onClick={() => chartRef.current?.resetZoom()}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Reset Zoom
|
||||
@ -1376,12 +1376,25 @@ const fetchMilestones = useCallback(async () => {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="mt-4 space-x-2">
|
||||
<label className="font-medium">Simulation Length (years):</label>
|
||||
@ -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 */}
|
||||
{/* <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 ---------------------------------- */
|
||||
export default function MilestonePanel({ groups, onEdit }) {
|
||||
export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
||||
return (
|
||||
<aside className="w-full md:w-56 pr-4">
|
||||
<aside className="w-full pr-4">
|
||||
{groups.map(g => (
|
||||
<details key={g.month} className="mb-2">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
@ -14,14 +14,25 @@ export default function MilestonePanel({ groups, onEdit }) {
|
||||
|
||||
<ul className="mt-1 space-y-2">
|
||||
{g.items.map(m => (
|
||||
<li key={m.id} className="flex items-start gap-1 text-sm">
|
||||
<span className="flex-1">{m.title}</span>
|
||||
<Button
|
||||
onClick={() => onEdit(m)}
|
||||
size="icon"
|
||||
className="bg-transparent hover:bg-muted p-1 rounded-sm"
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-start justify-between text-sm group cursor-pointer"
|
||||
onClick={() => onSelect?.(m)}
|
||||
>
|
||||
<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>
|
||||
</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 }) => (
|
||||
<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