diff --git a/api/staff/okrs.ts b/api/staff/okrs.ts index deee58b0..6335385c 100644 --- a/api/staff/okrs.ts +++ b/api/staff/okrs.ts @@ -1,57 +1,208 @@ import { supabase } from "../_supabase.js"; export default async (req: Request) => { - if (req.method !== "GET") { - return new Response("Method not allowed", { status: 405 }); + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); } + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + try { - const token = req.headers.get("Authorization")?.replace("Bearer ", ""); - if (!token) { - return new Response("Unauthorized", { status: 401 }); - } + // GET - Fetch OKRs with key results + if (req.method === "GET") { + const quarter = url.searchParams.get("quarter"); + const year = url.searchParams.get("year"); + const status = url.searchParams.get("status"); - const { data: userData } = await supabase.auth.getUser(token); - if (!userData.user) { - return new Response("Unauthorized", { status: 401 }); - } + let query = supabase + .from("staff_okrs") + .select(` + *, + key_results:staff_key_results(*) + `) + .or(`user_id.eq.${userId},owner_type.in.(team,company)`) + .order("created_at", { ascending: false }); - const { data: okrs, error } = await supabase - .from("staff_okrs") - .select( - ` - id, - user_id, - objective, - description, - status, - quarter, - year, - key_results( - id, - title, - progress, - target_value - ), - created_at - `, - ) - .eq("user_id", userData.user.id) - .order("created_at", { ascending: false }); + if (quarter) query = query.eq("quarter", parseInt(quarter)); + if (year) query = query.eq("year", parseInt(year)); + if (status) query = query.eq("status", status); - if (error) { - console.error("OKRs fetch error:", error); - return new Response(JSON.stringify({ error: error.message }), { - status: 500, + const { data: okrs, error } = await query; + if (error) throw error; + + // Calculate stats + const myOkrs = okrs?.filter(o => o.user_id === userId) || []; + const stats = { + total: myOkrs.length, + active: myOkrs.filter(o => o.status === "active").length, + completed: myOkrs.filter(o => o.status === "completed").length, + avgProgress: myOkrs.length > 0 + ? Math.round(myOkrs.reduce((sum, o) => sum + (o.progress || 0), 0) / myOkrs.length) + : 0 + }; + + return new Response(JSON.stringify({ okrs: okrs || [], stats }), { + headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify(okrs || []), { - headers: { "Content-Type": "application/json" }, - }); + // POST - Create OKR or Key Result + if (req.method === "POST") { + const body = await req.json(); + + // Create new OKR + if (body.action === "create_okr") { + const { objective, description, quarter, year, team, owner_type } = body; + + const { data: okr, error } = await supabase + .from("staff_okrs") + .insert({ + user_id: userId, + objective, + description, + quarter, + year, + team, + owner_type: owner_type || "individual", + status: "draft" + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ okr }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Add key result to OKR + if (body.action === "add_key_result") { + const { okr_id, title, description, target_value, metric_type, unit, due_date } = body; + + const { data: keyResult, error } = await supabase + .from("staff_key_results") + .insert({ + okr_id, + title, + description, + target_value, + metric_type: metric_type || "percentage", + unit, + due_date + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ key_result: keyResult }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Update key result progress + if (body.action === "update_key_result") { + const { key_result_id, current_value, status } = body; + + // Get target value to calculate progress + const { data: kr } = await supabase + .from("staff_key_results") + .select("target_value, start_value") + .eq("id", key_result_id) + .single(); + + const progress = kr ? Math.min(100, Math.round(((current_value - (kr.start_value || 0)) / (kr.target_value - (kr.start_value || 0))) * 100)) : 0; + + const { data: keyResult, error } = await supabase + .from("staff_key_results") + .update({ + current_value, + progress: Math.max(0, progress), + status: status || (progress >= 100 ? "completed" : progress >= 70 ? "on_track" : progress >= 40 ? "at_risk" : "behind"), + updated_at: new Date().toISOString() + }) + .eq("id", key_result_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ key_result: keyResult }), { headers: { "Content-Type": "application/json" } }); + } + + // Add check-in + if (body.action === "add_checkin") { + const { okr_id, notes, progress_snapshot } = body; + + const { data: checkin, error } = await supabase + .from("staff_okr_checkins") + .insert({ + okr_id, + user_id: userId, + notes, + progress_snapshot + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ checkin }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // PUT - Update OKR + if (req.method === "PUT") { + const body = await req.json(); + const { id, objective, description, status, quarter, year } = body; + + const { data: okr, error } = await supabase + .from("staff_okrs") + .update({ + objective, + description, + status, + quarter, + year, + updated_at: new Date().toISOString() + }) + .eq("id", id) + .eq("user_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ okr }), { headers: { "Content-Type": "application/json" } }); + } + + // DELETE - Delete OKR or Key Result + if (req.method === "DELETE") { + const id = url.searchParams.get("id"); + const type = url.searchParams.get("type") || "okr"; + + if (type === "key_result") { + const { error } = await supabase + .from("staff_key_results") + .delete() + .eq("id", id); + if (error) throw error; + } else { + const { error } = await supabase + .from("staff_okrs") + .delete() + .eq("id", id) + .eq("user_id", userId); + if (error) throw error; + } + + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); } catch (err: any) { - return new Response(JSON.stringify({ error: err.message }), { - status: 500, - }); + console.error("OKR API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); } }; diff --git a/api/staff/time-tracking.ts b/api/staff/time-tracking.ts new file mode 100644 index 00000000..73f5b609 --- /dev/null +++ b/api/staff/time-tracking.ts @@ -0,0 +1,245 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + + try { + // GET - Fetch time entries and timesheets + if (req.method === "GET") { + const startDate = url.searchParams.get("start_date"); + const endDate = url.searchParams.get("end_date"); + const view = url.searchParams.get("view") || "week"; // week, month, all + + // Calculate default date range based on view + const now = new Date(); + let defaultStart: string; + let defaultEnd: string; + + if (view === "week") { + const dayOfWeek = now.getDay(); + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - dayOfWeek); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + defaultStart = weekStart.toISOString().split("T")[0]; + defaultEnd = weekEnd.toISOString().split("T")[0]; + } else if (view === "month") { + defaultStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split("T")[0]; + defaultEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split("T")[0]; + } else { + defaultStart = new Date(now.getFullYear(), 0, 1).toISOString().split("T")[0]; + defaultEnd = new Date(now.getFullYear(), 11, 31).toISOString().split("T")[0]; + } + + const rangeStart = startDate || defaultStart; + const rangeEnd = endDate || defaultEnd; + + // Get time entries + const { data: entries, error: entriesError } = await supabase + .from("staff_time_entries") + .select(` + *, + project:staff_projects(id, name), + task:staff_project_tasks(id, title) + `) + .eq("user_id", userId) + .gte("date", rangeStart) + .lte("date", rangeEnd) + .order("date", { ascending: false }) + .order("start_time", { ascending: false }); + + if (entriesError) throw entriesError; + + // Get projects for dropdown + const { data: projects } = await supabase + .from("staff_projects") + .select("id, name") + .or(`lead_id.eq.${userId},team_members.cs.{${userId}}`) + .eq("status", "active"); + + // Calculate stats + const totalMinutes = entries?.reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0; + const billableMinutes = entries?.filter(e => e.is_billable).reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0; + + const stats = { + totalHours: Math.round((totalMinutes / 60) * 10) / 10, + billableHours: Math.round((billableMinutes / 60) * 10) / 10, + entriesCount: entries?.length || 0, + avgHoursPerDay: entries?.length ? Math.round((totalMinutes / 60 / new Set(entries.map(e => e.date)).size) * 10) / 10 : 0 + }; + + return new Response(JSON.stringify({ + entries: entries || [], + projects: projects || [], + stats, + dateRange: { start: rangeStart, end: rangeEnd } + }), { headers: { "Content-Type": "application/json" } }); + } + + // POST - Create time entry or actions + if (req.method === "POST") { + const body = await req.json(); + + // Create time entry + if (body.action === "create_entry") { + const { project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body; + + // Calculate duration if start/end provided + let calculatedDuration = duration_minutes; + if (start_time && end_time && !duration_minutes) { + const [sh, sm] = start_time.split(":").map(Number); + const [eh, em] = end_time.split(":").map(Number); + calculatedDuration = (eh * 60 + em) - (sh * 60 + sm); + } + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .insert({ + user_id: userId, + project_id, + task_id, + description, + date: date || new Date().toISOString().split("T")[0], + start_time, + end_time, + duration_minutes: calculatedDuration || 0, + is_billable: is_billable !== false, + notes + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Start timer (quick entry) + if (body.action === "start_timer") { + const { project_id, description } = body; + const now = new Date(); + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .insert({ + user_id: userId, + project_id, + description: description || "Time tracking", + date: now.toISOString().split("T")[0], + start_time: now.toTimeString().split(" ")[0].substring(0, 5), + duration_minutes: 0, + is_billable: true + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Stop timer + if (body.action === "stop_timer") { + const { entry_id } = body; + const now = new Date(); + const endTime = now.toTimeString().split(" ")[0].substring(0, 5); + + // Get the entry to calculate duration + const { data: existing } = await supabase + .from("staff_time_entries") + .select("start_time") + .eq("id", entry_id) + .single(); + + if (existing?.start_time) { + const [sh, sm] = existing.start_time.split(":").map(Number); + const [eh, em] = endTime.split(":").map(Number); + const duration = (eh * 60 + em) - (sh * 60 + sm); + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .update({ + end_time: endTime, + duration_minutes: Math.max(0, duration), + updated_at: new Date().toISOString() + }) + .eq("id", entry_id) + .eq("user_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // PUT - Update time entry + if (req.method === "PUT") { + const body = await req.json(); + const { id, project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body; + + // Calculate duration if times provided + let calculatedDuration = duration_minutes; + if (start_time && end_time) { + const [sh, sm] = start_time.split(":").map(Number); + const [eh, em] = end_time.split(":").map(Number); + calculatedDuration = (eh * 60 + em) - (sh * 60 + sm); + } + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .update({ + project_id, + task_id, + description, + date, + start_time, + end_time, + duration_minutes: calculatedDuration, + is_billable, + notes, + updated_at: new Date().toISOString() + }) + .eq("id", id) + .eq("user_id", userId) + .eq("status", "draft") + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } }); + } + + // DELETE - Delete time entry + if (req.method === "DELETE") { + const id = url.searchParams.get("id"); + + const { error } = await supabase + .from("staff_time_entries") + .delete() + .eq("id", id) + .eq("user_id", userId) + .eq("status", "draft"); + + if (error) throw error; + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Time tracking API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/client/App.tsx b/client/App.tsx index 7f84f2f2..529560d7 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -165,6 +165,8 @@ import CandidatePortal from "./pages/candidate/CandidatePortal"; import CandidateProfile from "./pages/candidate/CandidateProfile"; import CandidateInterviews from "./pages/candidate/CandidateInterviews"; import CandidateOffers from "./pages/candidate/CandidateOffers"; +import StaffOKRs from "./pages/staff/StaffOKRs"; +import StaffTimeTracking from "./pages/staff/StaffTimeTracking"; const queryClient = new QueryClient(); @@ -545,6 +547,22 @@ const App = () => ( } /> + + + + } + /> + + + + } + /> {/* Candidate Portal Routes */} { + switch (status) { + case "completed": + return "bg-green-500/20 text-green-300 border-green-500/30"; + case "active": + return "bg-blue-500/20 text-blue-300 border-blue-500/30"; + case "draft": + return "bg-slate-500/20 text-slate-300 border-slate-500/30"; + case "on_track": + return "bg-green-500/20 text-green-300"; + case "at_risk": + return "bg-amber-500/20 text-amber-300"; + case "behind": + return "bg-red-500/20 text-red-300"; + default: + return "bg-slate-500/20 text-slate-300"; + } +}; + +export default function StaffOKRs() { + const { session } = useAuth(); + const [okrs, setOkrs] = useState([]); + const [stats, setStats] = useState({ total: 0, active: 0, completed: 0, avgProgress: 0 }); + const [loading, setLoading] = useState(true); + const [expandedOkr, setExpandedOkr] = useState(null); + const [selectedQuarter, setSelectedQuarter] = useState(currentQuarter.toString()); + const [selectedYear, setSelectedYear] = useState(currentYear.toString()); + + // Dialog states + const [createOkrDialog, setCreateOkrDialog] = useState(false); + const [addKrDialog, setAddKrDialog] = useState(null); + const [updateKrDialog, setUpdateKrDialog] = useState(null); + + // Form states + const [newOkr, setNewOkr] = useState({ objective: "", description: "", quarter: currentQuarter, year: currentYear }); + const [newKr, setNewKr] = useState({ title: "", description: "", target_value: 100, metric_type: "percentage", unit: "", due_date: "" }); + const [krUpdate, setKrUpdate] = useState({ current_value: 0 }); + + useEffect(() => { + if (session?.access_token) { + fetchOkrs(); + } + }, [session?.access_token, selectedQuarter, selectedYear]); + + const fetchOkrs = async () => { + try { + const params = new URLSearchParams(); + if (selectedQuarter !== "all") params.append("quarter", selectedQuarter); + if (selectedYear !== "all") params.append("year", selectedYear); + + const res = await fetch(`/api/staff/okrs?${params}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + const data = await res.json(); + if (res.ok) { + setOkrs(data.okrs || []); + setStats(data.stats || { total: 0, active: 0, completed: 0, avgProgress: 0 }); + } + } catch (err) { + aethexToast.error("Failed to load OKRs"); + } finally { + setLoading(false); + } + }; + + const createOkr = async () => { + if (!newOkr.objective) return; + try { + const res = await fetch("/api/staff/okrs", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ action: "create_okr", ...newOkr }), + }); + if (res.ok) { + aethexToast.success("OKR created!"); + setCreateOkrDialog(false); + setNewOkr({ objective: "", description: "", quarter: currentQuarter, year: currentYear }); + fetchOkrs(); + } + } catch (err) { + aethexToast.error("Failed to create OKR"); + } + }; + + const addKeyResult = async () => { + if (!addKrDialog || !newKr.title) return; + try { + const res = await fetch("/api/staff/okrs", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ action: "add_key_result", okr_id: addKrDialog, ...newKr }), + }); + if (res.ok) { + aethexToast.success("Key Result added!"); + setAddKrDialog(null); + setNewKr({ title: "", description: "", target_value: 100, metric_type: "percentage", unit: "", due_date: "" }); + fetchOkrs(); + } + } catch (err) { + aethexToast.error("Failed to add Key Result"); + } + }; + + const updateKeyResult = async () => { + if (!updateKrDialog) return; + try { + const res = await fetch("/api/staff/okrs", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ action: "update_key_result", key_result_id: updateKrDialog.id, ...krUpdate }), + }); + if (res.ok) { + aethexToast.success("Progress updated!"); + setUpdateKrDialog(null); + fetchOkrs(); + } + } catch (err) { + aethexToast.error("Failed to update progress"); + } + }; + + const activateOkr = async (okrId: string) => { + try { + const res = await fetch("/api/staff/okrs", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ id: okrId, status: "active" }), + }); + if (res.ok) { + aethexToast.success("OKR activated!"); + fetchOkrs(); + } + } catch (err) { + aethexToast.error("Failed to activate OKR"); + } + }; + + const deleteOkr = async (okrId: string) => { + if (!confirm("Delete this OKR and all its key results?")) return; + try { + const res = await fetch(`/api/staff/okrs?id=${okrId}&type=okr`, { + method: "DELETE", + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + if (res.ok) { + aethexToast.success("OKR deleted"); + fetchOkrs(); + } + } catch (err) { + aethexToast.error("Failed to delete OKR"); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + + + +
+
+
+
+
+ +
+
+ {/* Header */} +
+
+
+ +
+
+

OKRs

+

Objectives and Key Results

+
+
+ +
+ + {/* Stats */} +
+ + +
+
+

Total OKRs

+

{stats.total}

+
+ +
+
+
+ + +
+
+

Active

+

{stats.active}

+
+ +
+
+
+ + +
+
+

Completed

+

{stats.completed}

+
+ +
+
+
+ + +
+
+

Avg Progress

+

{stats.avgProgress}%

+
+ +
+
+
+
+ + {/* Filters */} +
+ + +
+ + {/* OKRs List */} +
+ {okrs.map((okr) => ( + + setExpandedOkr(expandedOkr === okr.id ? null : okr.id)} + > +
+
+
+ + {okr.status.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} + + + Q{okr.quarter} {okr.year} + +
+ {okr.objective} + {okr.description && ( + + {okr.description} + + )} +
+
+
+

{okr.progress}%

+

{okr.key_results?.length || 0} Key Results

+
+ {expandedOkr === okr.id ? ( + + ) : ( + + )} +
+
+ +
+ + {expandedOkr === okr.id && ( + +
+
+

Key Results

+
+ {okr.status === "draft" && ( + + )} + + +
+
+ +
+ {okr.key_results?.map((kr) => ( +
{ + setUpdateKrDialog(kr); + setKrUpdate({ current_value: kr.current_value }); + }} + > +
+

{kr.title}

+ + {kr.status.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} + +
+
+ + + {kr.current_value} / {kr.target_value} {kr.unit} + +
+ {kr.due_date && ( +

+ Due: {new Date(kr.due_date).toLocaleDateString()} +

+ )} +
+ ))} + {(!okr.key_results || okr.key_results.length === 0) && ( +

No key results yet. Add one to track progress.

+ )} +
+
+
+ )} +
+ ))} +
+ + {okrs.length === 0 && ( +
+ +

No OKRs found for this period

+ +
+ )} +
+
+
+ + {/* Create OKR Dialog */} + + + + Create New OKR + +
+ setNewOkr({ ...newOkr, objective: e.target.value })} + className="bg-slate-700 border-slate-600 text-slate-100" + /> +