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";
|
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
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 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
|
||||||
|
|
|
||||||
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