Add OKR management and time tracking features

OKR Management:
- Database tables for OKRs, key results, and check-ins
- Full CRUD API with progress auto-calculation
- UI with quarter/year filtering, create/edit dialogs
- Key result progress tracking with status indicators
- Trigger to auto-update OKR progress from key results

Time Tracking:
- Database tables for time entries and timesheets
- API with timer start/stop, manual entry creation
- Week/month/all view with grouped entries by date
- Stats for total hours, billable hours, avg per day
- Real-time timer with running indicator

Both features include RLS policies and proper indexes.
This commit is contained in:
Claude 2026-01-26 22:36:16 +00:00
parent 01026d43cc
commit ebf62ec80e
No known key found for this signature in database
7 changed files with 1940 additions and 42 deletions

View file

@ -1,57 +1,208 @@
import { supabase } from "../_supabase.js"; import { supabase } from "../_supabase.js";
export default async (req: Request) => { export default async (req: Request) => {
if (req.method !== "GET") { const token = req.headers.get("Authorization")?.replace("Bearer ", "");
return new Response("Method not allowed", { status: 405 }); 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 { try {
const token = req.headers.get("Authorization")?.replace("Bearer ", ""); // GET - Fetch OKRs with key results
if (!token) { if (req.method === "GET") {
return new Response("Unauthorized", { status: 401 }); 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); let query = supabase
if (!userData.user) { .from("staff_okrs")
return new Response("Unauthorized", { status: 401 }); .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 if (quarter) query = query.eq("quarter", parseInt(quarter));
.from("staff_okrs") if (year) query = query.eq("year", parseInt(year));
.select( if (status) query = query.eq("status", status);
`
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 (error) { const { data: okrs, error } = await query;
console.error("OKRs fetch error:", error); if (error) throw error;
return new Response(JSON.stringify({ error: error.message }), {
status: 500, // 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 || []), { // POST - Create OKR or Key Result
headers: { "Content-Type": "application/json" }, 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) { } catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { console.error("OKR API error:", err);
status: 500, return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
});
} }
}; };

245
api/staff/time-tracking.ts Normal file
View file

@ -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" } });
}
};

View file

@ -165,6 +165,8 @@ import CandidatePortal from "./pages/candidate/CandidatePortal";
import CandidateProfile from "./pages/candidate/CandidateProfile"; import CandidateProfile from "./pages/candidate/CandidateProfile";
import CandidateInterviews from "./pages/candidate/CandidateInterviews"; import CandidateInterviews from "./pages/candidate/CandidateInterviews";
import CandidateOffers from "./pages/candidate/CandidateOffers"; import CandidateOffers from "./pages/candidate/CandidateOffers";
import StaffOKRs from "./pages/staff/StaffOKRs";
import StaffTimeTracking from "./pages/staff/StaffTimeTracking";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -545,6 +547,22 @@ const App = () => (
</RequireAccess> </RequireAccess>
} }
/> />
<Route
path="/staff/okrs"
element={
<RequireAccess>
<StaffOKRs />
</RequireAccess>
}
/>
<Route
path="/staff/time-tracking"
element={
<RequireAccess>
<StaffTimeTracking />
</RequireAccess>
}
/>
{/* Candidate Portal Routes */} {/* Candidate Portal Routes */}
<Route <Route

View file

@ -0,0 +1,655 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Target,
Plus,
TrendingUp,
CheckCircle,
AlertTriangle,
Loader2,
ChevronDown,
ChevronUp,
Trash2,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface KeyResult {
id: string;
title: string;
description?: string;
metric_type: string;
start_value: number;
current_value: number;
target_value: number;
unit?: string;
progress: number;
status: string;
due_date?: string;
}
interface OKR {
id: string;
objective: string;
description?: string;
status: string;
quarter: number;
year: number;
progress: number;
team?: string;
owner_type: string;
key_results: KeyResult[];
created_at: string;
}
interface Stats {
total: number;
active: number;
completed: number;
avgProgress: number;
}
const currentYear = new Date().getFullYear();
const currentQuarter = Math.ceil((new Date().getMonth() + 1) / 3);
const getStatusColor = (status: string) => {
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<OKR[]>([]);
const [stats, setStats] = useState<Stats>({ total: 0, active: 0, completed: 0, avgProgress: 0 });
const [loading, setLoading] = useState(true);
const [expandedOkr, setExpandedOkr] = useState<string | null>(null);
const [selectedQuarter, setSelectedQuarter] = useState(currentQuarter.toString());
const [selectedYear, setSelectedYear] = useState(currentYear.toString());
// Dialog states
const [createOkrDialog, setCreateOkrDialog] = useState(false);
const [addKrDialog, setAddKrDialog] = useState<string | null>(null);
const [updateKrDialog, setUpdateKrDialog] = useState<KeyResult | null>(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 (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="OKRs" description="Set and track your objectives and key results" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-emerald-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-teal-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-6xl px-4 py-16">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-emerald-500/20 border border-emerald-500/30">
<Target className="h-6 w-6 text-emerald-400" />
</div>
<div>
<h1 className="text-4xl font-bold text-emerald-100">OKRs</h1>
<p className="text-emerald-200/70">Objectives and Key Results</p>
</div>
</div>
<Button
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => setCreateOkrDialog(true)}
>
<Plus className="h-4 w-4 mr-2" />
New OKR
</Button>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-emerald-950/30 border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-emerald-200/70">Total OKRs</p>
<p className="text-3xl font-bold text-emerald-100">{stats.total}</p>
</div>
<Target className="h-8 w-8 text-emerald-400" />
</div>
</CardContent>
</Card>
<Card className="bg-emerald-950/30 border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-emerald-200/70">Active</p>
<p className="text-3xl font-bold text-emerald-100">{stats.active}</p>
</div>
<TrendingUp className="h-8 w-8 text-emerald-400" />
</div>
</CardContent>
</Card>
<Card className="bg-emerald-950/30 border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-emerald-200/70">Completed</p>
<p className="text-3xl font-bold text-emerald-100">{stats.completed}</p>
</div>
<CheckCircle className="h-8 w-8 text-emerald-400" />
</div>
</CardContent>
</Card>
<Card className="bg-emerald-950/30 border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-emerald-200/70">Avg Progress</p>
<p className="text-3xl font-bold text-emerald-100">{stats.avgProgress}%</p>
</div>
<AlertTriangle className="h-8 w-8 text-emerald-400" />
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex gap-4 mb-8">
<Select value={selectedQuarter} onValueChange={setSelectedQuarter}>
<SelectTrigger className="w-32 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quarters</SelectItem>
<SelectItem value="1">Q1</SelectItem>
<SelectItem value="2">Q2</SelectItem>
<SelectItem value="3">Q3</SelectItem>
<SelectItem value="4">Q4</SelectItem>
</SelectContent>
</Select>
<Select value={selectedYear} onValueChange={setSelectedYear}>
<SelectTrigger className="w-32 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Years</SelectItem>
<SelectItem value={(currentYear - 1).toString()}>{currentYear - 1}</SelectItem>
<SelectItem value={currentYear.toString()}>{currentYear}</SelectItem>
<SelectItem value={(currentYear + 1).toString()}>{currentYear + 1}</SelectItem>
</SelectContent>
</Select>
</div>
{/* OKRs List */}
<div className="space-y-6">
{okrs.map((okr) => (
<Card key={okr.id} className="bg-slate-800/50 border-slate-700/50 hover:border-emerald-500/50 transition-all">
<CardHeader
className="cursor-pointer"
onClick={() => setExpandedOkr(expandedOkr === okr.id ? null : okr.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={`border ${getStatusColor(okr.status)}`}>
{okr.status.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
<Badge className="bg-slate-700 text-slate-300">
Q{okr.quarter} {okr.year}
</Badge>
</div>
<CardTitle className="text-emerald-100">{okr.objective}</CardTitle>
{okr.description && (
<CardDescription className="text-slate-400 mt-1">
{okr.description}
</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right mr-4">
<p className="text-2xl font-bold text-emerald-300">{okr.progress}%</p>
<p className="text-xs text-slate-500">{okr.key_results?.length || 0} Key Results</p>
</div>
{expandedOkr === okr.id ? (
<ChevronUp className="h-5 w-5 text-emerald-400" />
) : (
<ChevronDown className="h-5 w-5 text-emerald-400" />
)}
</div>
</div>
<Progress value={okr.progress} className="h-2 mt-4" />
</CardHeader>
{expandedOkr === okr.id && (
<CardContent className="pt-0">
<div className="border-t border-slate-700 pt-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-semibold text-emerald-100">Key Results</h4>
<div className="flex gap-2">
{okr.status === "draft" && (
<Button
size="sm"
variant="outline"
className="border-emerald-500/30 text-emerald-300"
onClick={() => activateOkr(okr.id)}
>
Activate
</Button>
)}
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => setAddKrDialog(okr.id)}
>
<Plus className="h-4 w-4 mr-1" />
Add KR
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={() => deleteOkr(okr.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-3">
{okr.key_results?.map((kr) => (
<div
key={kr.id}
className="p-4 bg-slate-700/30 rounded-lg cursor-pointer hover:bg-slate-700/50 transition-colors"
onClick={() => {
setUpdateKrDialog(kr);
setKrUpdate({ current_value: kr.current_value });
}}
>
<div className="flex items-center justify-between mb-2">
<p className="text-slate-200 font-medium">{kr.title}</p>
<Badge className={getStatusColor(kr.status)}>
{kr.status.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())}
</Badge>
</div>
<div className="flex items-center gap-4">
<Progress value={kr.progress} className="flex-1 h-2" />
<span className="text-sm text-emerald-300 w-24 text-right">
{kr.current_value} / {kr.target_value} {kr.unit}
</span>
</div>
{kr.due_date && (
<p className="text-xs text-slate-500 mt-2">
Due: {new Date(kr.due_date).toLocaleDateString()}
</p>
)}
</div>
))}
{(!okr.key_results || okr.key_results.length === 0) && (
<p className="text-slate-500 text-center py-4">No key results yet. Add one to track progress.</p>
)}
</div>
</div>
</CardContent>
)}
</Card>
))}
</div>
{okrs.length === 0 && (
<div className="text-center py-12">
<Target className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400">No OKRs found for this period</p>
<Button
className="mt-4 bg-emerald-600 hover:bg-emerald-700"
onClick={() => setCreateOkrDialog(true)}
>
Create Your First OKR
</Button>
</div>
)}
</div>
</div>
</div>
{/* Create OKR Dialog */}
<Dialog open={createOkrDialog} onOpenChange={setCreateOkrDialog}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-emerald-100">Create New OKR</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="Objective"
value={newOkr.objective}
onChange={(e) => setNewOkr({ ...newOkr, objective: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<Textarea
placeholder="Description (optional)"
value={newOkr.description}
onChange={(e) => setNewOkr({ ...newOkr, description: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="grid grid-cols-2 gap-4">
<Select
value={newOkr.quarter.toString()}
onValueChange={(v) => setNewOkr({ ...newOkr, quarter: parseInt(v) })}
>
<SelectTrigger className="bg-slate-700 border-slate-600 text-slate-100">
<SelectValue placeholder="Quarter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Q1</SelectItem>
<SelectItem value="2">Q2</SelectItem>
<SelectItem value="3">Q3</SelectItem>
<SelectItem value="4">Q4</SelectItem>
</SelectContent>
</Select>
<Select
value={newOkr.year.toString()}
onValueChange={(v) => setNewOkr({ ...newOkr, year: parseInt(v) })}
>
<SelectTrigger className="bg-slate-700 border-slate-600 text-slate-100">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
<SelectItem value={(currentYear - 1).toString()}>{currentYear - 1}</SelectItem>
<SelectItem value={currentYear.toString()}>{currentYear}</SelectItem>
<SelectItem value={(currentYear + 1).toString()}>{currentYear + 1}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOkrDialog(false)}>
Cancel
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={createOkr}>
Create OKR
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Add Key Result Dialog */}
<Dialog open={!!addKrDialog} onOpenChange={() => setAddKrDialog(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-emerald-100">Add Key Result</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="Key Result title"
value={newKr.title}
onChange={(e) => setNewKr({ ...newKr, title: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<Textarea
placeholder="Description (optional)"
value={newKr.description}
onChange={(e) => setNewKr({ ...newKr, description: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-slate-400 mb-1 block">Target Value</label>
<Input
type="number"
value={newKr.target_value}
onChange={(e) => setNewKr({ ...newKr, target_value: parseFloat(e.target.value) })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div>
<label className="text-sm text-slate-400 mb-1 block">Unit (optional)</label>
<Input
placeholder="e.g., %, users, $"
value={newKr.unit}
onChange={(e) => setNewKr({ ...newKr, unit: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
</div>
<div>
<label className="text-sm text-slate-400 mb-1 block">Due Date (optional)</label>
<Input
type="date"
value={newKr.due_date}
onChange={(e) => setNewKr({ ...newKr, due_date: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAddKrDialog(null)}>
Cancel
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={addKeyResult}>
Add Key Result
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Update Key Result Dialog */}
<Dialog open={!!updateKrDialog} onOpenChange={() => setUpdateKrDialog(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-emerald-100">Update Progress</DialogTitle>
</DialogHeader>
{updateKrDialog && (
<div className="space-y-4">
<p className="text-slate-300">{updateKrDialog.title}</p>
<div className="p-4 bg-slate-700/50 rounded">
<div className="flex justify-between text-sm mb-2">
<span className="text-slate-400">Current Progress</span>
<span className="text-emerald-300">{updateKrDialog.progress}%</span>
</div>
<Progress value={updateKrDialog.progress} className="h-2" />
</div>
<div>
<label className="text-sm text-slate-400 mb-1 block">
New Value (Target: {updateKrDialog.target_value} {updateKrDialog.unit})
</label>
<Input
type="number"
value={krUpdate.current_value}
onChange={(e) => setKrUpdate({ current_value: parseFloat(e.target.value) })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setUpdateKrDialog(null)}>
Cancel
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={updateKeyResult}>
Update
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</Layout>
);
}

View file

@ -0,0 +1,584 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Clock,
Play,
Square,
Plus,
Loader2,
Calendar,
Timer,
DollarSign,
Trash2,
Edit,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface Project {
id: string;
name: string;
}
interface TimeEntry {
id: string;
description: string;
date: string;
start_time?: string;
end_time?: string;
duration_minutes: number;
is_billable: boolean;
status: string;
notes?: string;
project?: Project;
task?: { id: string; title: string };
}
interface Stats {
totalHours: number;
billableHours: number;
entriesCount: number;
avgHoursPerDay: number;
}
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
const formatTime = (time?: string) => {
if (!time) return "-";
return time.substring(0, 5);
};
export default function StaffTimeTracking() {
const { session } = useAuth();
const [entries, setEntries] = useState<TimeEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [stats, setStats] = useState<Stats>({ totalHours: 0, billableHours: 0, entriesCount: 0, avgHoursPerDay: 0 });
const [loading, setLoading] = useState(true);
const [view, setView] = useState("week");
const [activeTimer, setActiveTimer] = useState<TimeEntry | null>(null);
// Dialog states
const [createDialog, setCreateDialog] = useState(false);
const [editEntry, setEditEntry] = useState<TimeEntry | null>(null);
// Form state
const [newEntry, setNewEntry] = useState({
project_id: "",
description: "",
date: new Date().toISOString().split("T")[0],
start_time: "",
end_time: "",
duration_minutes: 0,
is_billable: true,
notes: ""
});
useEffect(() => {
if (session?.access_token) {
fetchEntries();
}
}, [session?.access_token, view]);
// Check for running timer
useEffect(() => {
const running = entries.find(e => e.start_time && !e.end_time && e.duration_minutes === 0);
setActiveTimer(running || null);
}, [entries]);
const fetchEntries = async () => {
try {
const res = await fetch(`/api/staff/time-tracking?view=${view}`, {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
const data = await res.json();
if (res.ok) {
setEntries(data.entries || []);
setProjects(data.projects || []);
setStats(data.stats || { totalHours: 0, billableHours: 0, entriesCount: 0, avgHoursPerDay: 0 });
}
} catch (err) {
aethexToast.error("Failed to load time entries");
} finally {
setLoading(false);
}
};
const createEntry = async () => {
if (!newEntry.description && !newEntry.project_id) {
aethexToast.error("Please add a description or project");
return;
}
// Calculate duration from times if provided
let duration = newEntry.duration_minutes;
if (newEntry.start_time && newEntry.end_time) {
const [sh, sm] = newEntry.start_time.split(":").map(Number);
const [eh, em] = newEntry.end_time.split(":").map(Number);
duration = (eh * 60 + em) - (sh * 60 + sm);
}
try {
const res = await fetch("/api/staff/time-tracking", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "create_entry",
...newEntry,
duration_minutes: duration
}),
});
if (res.ok) {
aethexToast.success("Time entry created!");
setCreateDialog(false);
resetForm();
fetchEntries();
}
} catch (err) {
aethexToast.error("Failed to create entry");
}
};
const startTimer = async () => {
try {
const res = await fetch("/api/staff/time-tracking", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "start_timer",
description: "Time tracking"
}),
});
if (res.ok) {
aethexToast.success("Timer started!");
fetchEntries();
}
} catch (err) {
aethexToast.error("Failed to start timer");
}
};
const stopTimer = async () => {
if (!activeTimer) return;
try {
const res = await fetch("/api/staff/time-tracking", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "stop_timer",
entry_id: activeTimer.id
}),
});
if (res.ok) {
aethexToast.success("Timer stopped!");
fetchEntries();
}
} catch (err) {
aethexToast.error("Failed to stop timer");
}
};
const deleteEntry = async (entryId: string) => {
if (!confirm("Delete this time entry?")) return;
try {
const res = await fetch(`/api/staff/time-tracking?id=${entryId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (res.ok) {
aethexToast.success("Entry deleted");
fetchEntries();
}
} catch (err) {
aethexToast.error("Failed to delete entry");
}
};
const resetForm = () => {
setNewEntry({
project_id: "",
description: "",
date: new Date().toISOString().split("T")[0],
start_time: "",
end_time: "",
duration_minutes: 0,
is_billable: true,
notes: ""
});
};
// Group entries by date
const groupedEntries = entries.reduce((groups, entry) => {
const date = entry.date;
if (!groups[date]) groups[date] = [];
groups[date].push(entry);
return groups;
}, {} as Record<string, TimeEntry[]>);
if (loading) {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Time Tracking" description="Track your work hours and projects" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-cyan-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-6xl px-4 py-16">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-blue-500/20 border border-blue-500/30">
<Clock className="h-6 w-6 text-blue-400" />
</div>
<div>
<h1 className="text-4xl font-bold text-blue-100">Time Tracking</h1>
<p className="text-blue-200/70">Track your work hours and projects</p>
</div>
</div>
<div className="flex gap-2">
{activeTimer ? (
<Button
className="bg-red-600 hover:bg-red-700"
onClick={stopTimer}
>
<Square className="h-4 w-4 mr-2" />
Stop Timer
</Button>
) : (
<Button
className="bg-green-600 hover:bg-green-700"
onClick={startTimer}
>
<Play className="h-4 w-4 mr-2" />
Start Timer
</Button>
)}
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={() => setCreateDialog(true)}
>
<Plus className="h-4 w-4 mr-2" />
Add Entry
</Button>
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-blue-950/30 border-blue-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-200/70">Total Hours</p>
<p className="text-3xl font-bold text-blue-100">{stats.totalHours}</p>
</div>
<Timer className="h-8 w-8 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-blue-950/30 border-blue-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-200/70">Billable</p>
<p className="text-3xl font-bold text-blue-100">{stats.billableHours}h</p>
</div>
<DollarSign className="h-8 w-8 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-blue-950/30 border-blue-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-200/70">Entries</p>
<p className="text-3xl font-bold text-blue-100">{stats.entriesCount}</p>
</div>
<Calendar className="h-8 w-8 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-blue-950/30 border-blue-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-200/70">Avg/Day</p>
<p className="text-3xl font-bold text-blue-100">{stats.avgHoursPerDay}h</p>
</div>
<Clock className="h-8 w-8 text-blue-400" />
</div>
</CardContent>
</Card>
</div>
{/* View Toggle */}
<div className="flex gap-2 mb-8">
{["week", "month", "all"].map((v) => (
<Button
key={v}
variant={view === v ? "default" : "outline"}
size="sm"
onClick={() => setView(v)}
className={view === v ? "bg-blue-600 hover:bg-blue-700" : "border-blue-500/30 text-blue-300"}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</Button>
))}
</div>
{/* Active Timer Banner */}
{activeTimer && (
<Card className="bg-green-950/30 border-green-500/30 mb-8">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<Clock className="h-8 w-8 text-green-400" />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full animate-pulse" />
</div>
<div>
<p className="text-green-100 font-semibold">Timer Running</p>
<p className="text-green-200/70 text-sm">
Started at {formatTime(activeTimer.start_time)} {activeTimer.description}
</p>
</div>
</div>
<Button
className="bg-red-600 hover:bg-red-700"
onClick={stopTimer}
>
<Square className="h-4 w-4 mr-2" />
Stop
</Button>
</div>
</CardContent>
</Card>
)}
{/* Time Entries by Date */}
<div className="space-y-6">
{Object.entries(groupedEntries).map(([date, dayEntries]) => (
<div key={date}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-blue-100">
{new Date(date).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}
</h3>
<span className="text-sm text-blue-300">
{formatDuration(dayEntries.reduce((sum, e) => sum + e.duration_minutes, 0))}
</span>
</div>
<div className="space-y-2">
{dayEntries.map((entry) => (
<Card key={entry.id} className="bg-slate-800/50 border-slate-700/50 hover:border-blue-500/50 transition-all">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="text-center w-20">
<p className="text-xl font-bold text-blue-300">
{formatDuration(entry.duration_minutes)}
</p>
<p className="text-xs text-slate-500">
{formatTime(entry.start_time)} - {formatTime(entry.end_time)}
</p>
</div>
<div className="flex-1">
<p className="text-slate-200">{entry.description || "No description"}</p>
<div className="flex items-center gap-2 mt-1">
{entry.project && (
<Badge className="bg-blue-500/20 text-blue-300 text-xs">
{entry.project.name}
</Badge>
)}
{entry.is_billable && (
<Badge className="bg-green-500/20 text-green-300 text-xs">
Billable
</Badge>
)}
</div>
</div>
</div>
{entry.status === "draft" && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={() => deleteEntry(entry.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
{entries.length === 0 && (
<div className="text-center py-12">
<Clock className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400">No time entries for this period</p>
<Button
className="mt-4 bg-blue-600 hover:bg-blue-700"
onClick={() => setCreateDialog(true)}
>
Add Your First Entry
</Button>
</div>
)}
</div>
</div>
</div>
{/* Create Entry Dialog */}
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-blue-100">Add Time Entry</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="What did you work on?"
value={newEntry.description}
onChange={(e) => setNewEntry({ ...newEntry, description: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<Select
value={newEntry.project_id}
onValueChange={(v) => setNewEntry({ ...newEntry, project_id: v })}
>
<SelectTrigger className="bg-slate-700 border-slate-600 text-slate-100">
<SelectValue placeholder="Select project (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No project</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm text-slate-400 mb-1 block">Date</label>
<Input
type="date"
value={newEntry.date}
onChange={(e) => setNewEntry({ ...newEntry, date: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div>
<label className="text-sm text-slate-400 mb-1 block">Start Time</label>
<Input
type="time"
value={newEntry.start_time}
onChange={(e) => setNewEntry({ ...newEntry, start_time: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div>
<label className="text-sm text-slate-400 mb-1 block">End Time</label>
<Input
type="time"
value={newEntry.end_time}
onChange={(e) => setNewEntry({ ...newEntry, end_time: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-slate-400 mb-1 block">Duration (minutes)</label>
<Input
type="number"
placeholder="Or enter duration directly"
value={newEntry.duration_minutes || ""}
onChange={(e) => setNewEntry({ ...newEntry, duration_minutes: parseInt(e.target.value) || 0 })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={newEntry.is_billable}
onChange={(e) => setNewEntry({ ...newEntry, is_billable: e.target.checked })}
className="w-4 h-4"
/>
Billable
</label>
</div>
</div>
<Textarea
placeholder="Notes (optional)"
value={newEntry.notes}
onChange={(e) => setNewEntry({ ...newEntry, notes: e.target.value })}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => { setCreateDialog(false); resetForm(); }}>
Cancel
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={createEntry}>
Add Entry
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Layout>
);
}

View file

@ -0,0 +1,129 @@
-- OKR (Objectives and Key Results) Management Tables
-- Allows staff to set and track quarterly goals
-- Staff OKRs table
CREATE TABLE IF NOT EXISTS staff_okrs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
objective TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'completed', 'archived')),
quarter INTEGER NOT NULL CHECK (quarter BETWEEN 1 AND 4),
year INTEGER NOT NULL,
progress INTEGER DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
team TEXT,
owner_type TEXT DEFAULT 'individual' CHECK (owner_type IN ('individual', 'team', 'company')),
parent_okr_id UUID REFERENCES staff_okrs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Key Results table (linked to OKRs)
CREATE TABLE IF NOT EXISTS staff_key_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
okr_id UUID REFERENCES staff_okrs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
metric_type TEXT DEFAULT 'percentage' CHECK (metric_type IN ('percentage', 'number', 'currency', 'boolean')),
start_value DECIMAL DEFAULT 0,
current_value DECIMAL DEFAULT 0,
target_value DECIMAL NOT NULL,
unit TEXT,
progress INTEGER DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'on_track', 'at_risk', 'behind', 'completed')),
due_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Check-ins table for tracking OKR updates over time
CREATE TABLE IF NOT EXISTS staff_okr_checkins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
okr_id UUID REFERENCES staff_okrs(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
notes TEXT,
progress_snapshot INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE staff_okrs ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_key_results ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_okr_checkins ENABLE ROW LEVEL SECURITY;
-- RLS Policies for staff_okrs
CREATE POLICY "Users can view own OKRs" ON staff_okrs
FOR SELECT TO authenticated
USING (user_id = auth.uid() OR owner_type IN ('team', 'company'));
CREATE POLICY "Users can create OKRs" ON staff_okrs
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update own OKRs" ON staff_okrs
FOR UPDATE TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can delete own OKRs" ON staff_okrs
FOR DELETE TO authenticated
USING (user_id = auth.uid());
-- RLS Policies for key_results
CREATE POLICY "Users can view key results" ON staff_key_results
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_okrs
WHERE staff_okrs.id = staff_key_results.okr_id
AND (staff_okrs.user_id = auth.uid() OR staff_okrs.owner_type IN ('team', 'company'))
)
);
CREATE POLICY "Users can manage own key results" ON staff_key_results
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM staff_okrs
WHERE staff_okrs.id = staff_key_results.okr_id
AND staff_okrs.user_id = auth.uid()
)
);
-- RLS Policies for checkins
CREATE POLICY "Users can view checkins" ON staff_okr_checkins
FOR SELECT TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can create checkins" ON staff_okr_checkins
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_staff_okrs_user ON staff_okrs(user_id);
CREATE INDEX IF NOT EXISTS idx_staff_okrs_quarter ON staff_okrs(year, quarter);
CREATE INDEX IF NOT EXISTS idx_staff_okrs_status ON staff_okrs(status);
CREATE INDEX IF NOT EXISTS idx_staff_key_results_okr ON staff_key_results(okr_id);
CREATE INDEX IF NOT EXISTS idx_staff_okr_checkins_okr ON staff_okr_checkins(okr_id);
-- Function to calculate OKR progress based on key results
CREATE OR REPLACE FUNCTION calculate_okr_progress()
RETURNS TRIGGER AS $$
BEGIN
UPDATE staff_okrs
SET progress = (
SELECT COALESCE(AVG(progress), 0)::INTEGER
FROM staff_key_results
WHERE okr_id = NEW.okr_id
),
updated_at = NOW()
WHERE id = NEW.okr_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update OKR progress when key results change
DROP TRIGGER IF EXISTS update_okr_progress ON staff_key_results;
CREATE TRIGGER update_okr_progress
AFTER INSERT OR UPDATE OR DELETE ON staff_key_results
FOR EACH ROW
EXECUTE FUNCTION calculate_okr_progress();

View file

@ -0,0 +1,116 @@
-- Time Tracking Tables for Staff
-- Track work hours, projects, and generate timesheets
-- Time entries table
CREATE TABLE IF NOT EXISTS staff_time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
project_id UUID REFERENCES staff_projects(id) ON DELETE SET NULL,
task_id UUID REFERENCES staff_project_tasks(id) ON DELETE SET NULL,
description TEXT,
date DATE NOT NULL DEFAULT CURRENT_DATE,
start_time TIME,
end_time TIME,
duration_minutes INTEGER NOT NULL DEFAULT 0,
is_billable BOOLEAN DEFAULT true,
hourly_rate DECIMAL(10,2),
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Timesheets (weekly/monthly summaries)
CREATE TABLE IF NOT EXISTS staff_timesheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
total_hours DECIMAL(10,2) DEFAULT 0,
billable_hours DECIMAL(10,2) DEFAULT 0,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')),
submitted_at TIMESTAMPTZ,
approved_by UUID REFERENCES profiles(id),
approved_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, period_start, period_end)
);
-- Enable RLS
ALTER TABLE staff_time_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_timesheets ENABLE ROW LEVEL SECURITY;
-- RLS Policies for time_entries
CREATE POLICY "Users can view own time entries" ON staff_time_entries
FOR SELECT TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can create time entries" ON staff_time_entries
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update own time entries" ON staff_time_entries
FOR UPDATE TO authenticated
USING (user_id = auth.uid() AND status = 'draft');
CREATE POLICY "Users can delete draft entries" ON staff_time_entries
FOR DELETE TO authenticated
USING (user_id = auth.uid() AND status = 'draft');
-- RLS Policies for timesheets
CREATE POLICY "Users can view own timesheets" ON staff_timesheets
FOR SELECT TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can create timesheets" ON staff_timesheets
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update draft timesheets" ON staff_timesheets
FOR UPDATE TO authenticated
USING (user_id = auth.uid() AND status = 'draft');
-- Indexes
CREATE INDEX IF NOT EXISTS idx_time_entries_user ON staff_time_entries(user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_date ON staff_time_entries(date);
CREATE INDEX IF NOT EXISTS idx_time_entries_project ON staff_time_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_timesheets_user ON staff_timesheets(user_id);
CREATE INDEX IF NOT EXISTS idx_timesheets_period ON staff_timesheets(period_start, period_end);
-- Function to update timesheet totals
CREATE OR REPLACE FUNCTION update_timesheet_totals()
RETURNS TRIGGER AS $$
BEGIN
UPDATE staff_timesheets ts
SET
total_hours = (
SELECT COALESCE(SUM(duration_minutes) / 60.0, 0)
FROM staff_time_entries te
WHERE te.user_id = ts.user_id
AND te.date >= ts.period_start
AND te.date <= ts.period_end
),
billable_hours = (
SELECT COALESCE(SUM(duration_minutes) / 60.0, 0)
FROM staff_time_entries te
WHERE te.user_id = ts.user_id
AND te.date >= ts.period_start
AND te.date <= ts.period_end
AND te.is_billable = true
),
updated_at = NOW()
WHERE ts.user_id = COALESCE(NEW.user_id, OLD.user_id)
AND COALESCE(NEW.date, OLD.date) >= ts.period_start
AND COALESCE(NEW.date, OLD.date) <= ts.period_end;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update timesheet when time entries change
DROP TRIGGER IF EXISTS update_timesheet_on_entry_change ON staff_time_entries;
CREATE TRIGGER update_timesheet_on_entry_change
AFTER INSERT OR UPDATE OR DELETE ON staff_time_entries
FOR EACH ROW
EXECUTE FUNCTION update_timesheet_totals();