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:
parent
01026d43cc
commit
ebf62ec80e
7 changed files with 1940 additions and 42 deletions
|
|
@ -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" } });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
245
api/staff/time-tracking.ts
Normal file
245
api/staff/time-tracking.ts
Normal 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" } });
|
||||
}
|
||||
};
|
||||
|
|
@ -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 = () => (
|
|||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff/okrs"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<StaffOKRs />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff/time-tracking"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<StaffTimeTracking />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Candidate Portal Routes */}
|
||||
<Route
|
||||
|
|
|
|||
655
client/pages/staff/StaffOKRs.tsx
Normal file
655
client/pages/staff/StaffOKRs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
584
client/pages/staff/StaffTimeTracking.tsx
Normal file
584
client/pages/staff/StaffTimeTracking.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
supabase/migrations/20260126_add_okr_tables.sql
Normal file
129
supabase/migrations/20260126_add_okr_tables.sql
Normal 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();
|
||||
116
supabase/migrations/20260126_add_time_tracking.sql
Normal file
116
supabase/migrations/20260126_add_time_tracking.sql
Normal 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();
|
||||
Loading…
Reference in a new issue