Add staff feature APIs and update 2 pages to use real data
- Add database migration for staff features (announcements, expenses, courses, reviews, knowledge base, projects, marketplace, handbook) - Add 8 new API endpoints: announcements, expenses, courses, reviews, knowledge-base, projects, marketplace, handbook - Update StaffAnnouncements.tsx to use real API with read tracking - Update StaffExpenseReports.tsx to use real API with submit dialog More staff pages to be updated in next commit.
This commit is contained in:
parent
61fb02cd39
commit
f1efc97c86
11 changed files with 1253 additions and 421 deletions
62
api/staff/announcements.ts
Normal file
62
api/staff/announcements.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const { data: announcements, error } = await supabase
|
||||
.from("staff_announcements")
|
||||
.select(`*, author:profiles!staff_announcements_author_id_fkey(full_name, avatar_url)`)
|
||||
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
|
||||
.order("is_pinned", { ascending: false })
|
||||
.order("published_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Mark read status
|
||||
const withReadStatus = announcements?.map(a => ({
|
||||
...a,
|
||||
is_read: a.read_by?.includes(userId) || false
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ announcements: withReadStatus || [] }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
|
||||
// Mark as read
|
||||
if (body.action === "mark_read" && body.id) {
|
||||
const { data: current } = await supabase
|
||||
.from("staff_announcements")
|
||||
.select("read_by")
|
||||
.eq("id", body.id)
|
||||
.single();
|
||||
|
||||
const readBy = current?.read_by || [];
|
||||
if (!readBy.includes(userId)) {
|
||||
await supabase
|
||||
.from("staff_announcements")
|
||||
.update({ read_by: [...readBy, userId] })
|
||||
.eq("id", body.id);
|
||||
}
|
||||
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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
100
api/staff/courses.ts
Normal file
100
api/staff/courses.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
// Get all courses
|
||||
const { data: courses, error: coursesError } = await supabase
|
||||
.from("staff_courses")
|
||||
.select("*")
|
||||
.order("title");
|
||||
|
||||
if (coursesError) throw coursesError;
|
||||
|
||||
// Get user's progress
|
||||
const { data: progress, error: progressError } = await supabase
|
||||
.from("staff_course_progress")
|
||||
.select("*")
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (progressError) throw progressError;
|
||||
|
||||
// Merge progress with courses
|
||||
const coursesWithProgress = courses?.map(course => {
|
||||
const userProgress = progress?.find(p => p.course_id === course.id);
|
||||
return {
|
||||
...course,
|
||||
progress: userProgress?.progress_percent || 0,
|
||||
status: userProgress?.status || "available",
|
||||
started_at: userProgress?.started_at,
|
||||
completed_at: userProgress?.completed_at
|
||||
};
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: courses?.length || 0,
|
||||
completed: coursesWithProgress?.filter(c => c.status === "completed").length || 0,
|
||||
in_progress: coursesWithProgress?.filter(c => c.status === "in_progress").length || 0,
|
||||
required: courses?.filter(c => c.is_required).length || 0
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ courses: coursesWithProgress || [], stats }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
const { course_id, action, progress } = body;
|
||||
|
||||
if (action === "start") {
|
||||
const { data, error } = await supabase
|
||||
.from("staff_course_progress")
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
course_id,
|
||||
status: "in_progress",
|
||||
progress_percent: 0,
|
||||
started_at: new Date().toISOString()
|
||||
}, { onConflict: "user_id,course_id" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ progress: data }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (action === "update_progress") {
|
||||
const isComplete = progress >= 100;
|
||||
const { data, error } = await supabase
|
||||
.from("staff_course_progress")
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
course_id,
|
||||
progress_percent: Math.min(progress, 100),
|
||||
status: isComplete ? "completed" : "in_progress",
|
||||
completed_at: isComplete ? new Date().toISOString() : null
|
||||
}, { onConflict: "user_id,course_id" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ progress: data }), { 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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
96
api/staff/expenses.ts
Normal file
96
api/staff/expenses.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const { data: expenses, error } = await supabase
|
||||
.from("staff_expense_reports")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const stats = {
|
||||
total: expenses?.length || 0,
|
||||
pending: expenses?.filter(e => e.status === "pending").length || 0,
|
||||
approved: expenses?.filter(e => e.status === "approved").length || 0,
|
||||
reimbursed: expenses?.filter(e => e.status === "reimbursed").length || 0,
|
||||
total_amount: expenses?.reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0,
|
||||
pending_amount: expenses?.filter(e => e.status === "pending").reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ expenses: expenses || [], stats }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
const { title, description, amount, category, receipt_url } = body;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("staff_expense_reports")
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title,
|
||||
description,
|
||||
amount,
|
||||
category,
|
||||
receipt_url,
|
||||
status: "pending",
|
||||
submitted_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ expense: data }), { status: 201, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const body = await req.json();
|
||||
const { id, ...updates } = body;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("staff_expense_reports")
|
||||
.update(updates)
|
||||
.eq("id", id)
|
||||
.eq("user_id", userId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ expense: data }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
const { error } = await supabase
|
||||
.from("staff_expense_reports")
|
||||
.delete()
|
||||
.eq("id", id)
|
||||
.eq("user_id", userId)
|
||||
.in("status", ["draft", "pending"]);
|
||||
|
||||
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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
46
api/staff/handbook.ts
Normal file
46
api/staff/handbook.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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" } });
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const { data: sections, error } = await supabase
|
||||
.from("staff_handbook_sections")
|
||||
.select("*")
|
||||
.order("category")
|
||||
.order("order_index");
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Group by category
|
||||
const grouped = sections?.reduce((acc, section) => {
|
||||
if (!acc[section.category]) {
|
||||
acc[section.category] = [];
|
||||
}
|
||||
acc[section.category].push(section);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof sections>);
|
||||
|
||||
const categories = Object.keys(grouped || {});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
sections: sections || [],
|
||||
grouped: grouped || {},
|
||||
categories
|
||||
}), { 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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
72
api/staff/knowledge-base.ts
Normal file
72
api/staff/knowledge-base.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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 url = new URL(req.url);
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const category = url.searchParams.get("category");
|
||||
const search = url.searchParams.get("search");
|
||||
|
||||
let query = supabase
|
||||
.from("staff_knowledge_articles")
|
||||
.select(`*, author:profiles!staff_knowledge_articles_author_id_fkey(full_name, avatar_url)`)
|
||||
.eq("is_published", true)
|
||||
.order("views", { ascending: false });
|
||||
|
||||
if (category && category !== "all") {
|
||||
query = query.eq("category", category);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query = query.or(`title.ilike.%${search}%,content.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data: articles, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
// Get unique categories
|
||||
const { data: allArticles } = await supabase
|
||||
.from("staff_knowledge_articles")
|
||||
.select("category")
|
||||
.eq("is_published", true);
|
||||
|
||||
const categories = [...new Set(allArticles?.map(a => a.category) || [])];
|
||||
|
||||
return new Response(JSON.stringify({ articles: articles || [], categories }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
|
||||
// Increment view count
|
||||
if (body.action === "view" && body.id) {
|
||||
await supabase.rpc("increment_kb_views", { article_id: body.id });
|
||||
return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Mark as helpful
|
||||
if (body.action === "helpful" && body.id) {
|
||||
await supabase
|
||||
.from("staff_knowledge_articles")
|
||||
.update({ helpful_count: supabase.rpc("increment") })
|
||||
.eq("id", body.id);
|
||||
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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
126
api/staff/marketplace.ts
Normal file
126
api/staff/marketplace.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
// Get items
|
||||
const { data: items, error: itemsError } = await supabase
|
||||
.from("staff_marketplace_items")
|
||||
.select("*")
|
||||
.eq("is_available", true)
|
||||
.order("points_cost");
|
||||
|
||||
if (itemsError) throw itemsError;
|
||||
|
||||
// Get user's points
|
||||
let { data: points } = await supabase
|
||||
.from("staff_points")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
// Create points record if doesn't exist
|
||||
if (!points) {
|
||||
const { data: newPoints } = await supabase
|
||||
.from("staff_points")
|
||||
.insert({ user_id: userId, balance: 1000, lifetime_earned: 1000 })
|
||||
.select()
|
||||
.single();
|
||||
points = newPoints;
|
||||
}
|
||||
|
||||
// Get user's orders
|
||||
const { data: orders } = await supabase
|
||||
.from("staff_marketplace_orders")
|
||||
.select(`*, item:staff_marketplace_items(name, image_url)`)
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
items: items || [],
|
||||
points: points || { balance: 0, lifetime_earned: 0 },
|
||||
orders: orders || []
|
||||
}), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
const { item_id, quantity, shipping_address } = body;
|
||||
|
||||
// Get item
|
||||
const { data: item } = await supabase
|
||||
.from("staff_marketplace_items")
|
||||
.select("*")
|
||||
.eq("id", item_id)
|
||||
.single();
|
||||
|
||||
if (!item) {
|
||||
return new Response(JSON.stringify({ error: "Item not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Check stock
|
||||
if (item.stock_count !== null && item.stock_count < (quantity || 1)) {
|
||||
return new Response(JSON.stringify({ error: "Insufficient stock" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Check points
|
||||
const { data: points } = await supabase
|
||||
.from("staff_points")
|
||||
.select("balance")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
const totalCost = item.points_cost * (quantity || 1);
|
||||
if (!points || points.balance < totalCost) {
|
||||
return new Response(JSON.stringify({ error: "Insufficient points" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Create order
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from("staff_marketplace_orders")
|
||||
.insert({
|
||||
user_id: userId,
|
||||
item_id,
|
||||
quantity: quantity || 1,
|
||||
shipping_address,
|
||||
status: "pending"
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) throw orderError;
|
||||
|
||||
// Deduct points
|
||||
await supabase
|
||||
.from("staff_points")
|
||||
.update({ balance: points.balance - totalCost })
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Update stock if applicable
|
||||
if (item.stock_count !== null) {
|
||||
await supabase
|
||||
.from("staff_marketplace_items")
|
||||
.update({ stock_count: item.stock_count - (quantity || 1) })
|
||||
.eq("id", item_id);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ order }), { status: 201, 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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
102
api/staff/projects.ts
Normal file
102
api/staff/projects.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
// Get projects where user is lead or team member
|
||||
const { data: projects, error } = await supabase
|
||||
.from("staff_projects")
|
||||
.select(`
|
||||
*,
|
||||
lead:profiles!staff_projects_lead_id_fkey(full_name, avatar_url)
|
||||
`)
|
||||
.or(`lead_id.eq.${userId},team_members.cs.{${userId}}`)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Get tasks for each project
|
||||
const projectIds = projects?.map(p => p.id) || [];
|
||||
const { data: tasks } = await supabase
|
||||
.from("staff_project_tasks")
|
||||
.select("*")
|
||||
.in("project_id", projectIds);
|
||||
|
||||
// Attach tasks to projects
|
||||
const projectsWithTasks = projects?.map(project => ({
|
||||
...project,
|
||||
tasks: tasks?.filter(t => t.project_id === project.id) || [],
|
||||
task_stats: {
|
||||
total: tasks?.filter(t => t.project_id === project.id).length || 0,
|
||||
done: tasks?.filter(t => t.project_id === project.id && t.status === "done").length || 0
|
||||
}
|
||||
}));
|
||||
|
||||
const stats = {
|
||||
total: projects?.length || 0,
|
||||
active: projects?.filter(p => p.status === "active").length || 0,
|
||||
completed: projects?.filter(p => p.status === "completed").length || 0
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ projects: projectsWithTasks || [], stats }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
|
||||
// Update task status
|
||||
if (body.action === "update_task") {
|
||||
const { task_id, status } = body;
|
||||
const { data, error } = await supabase
|
||||
.from("staff_project_tasks")
|
||||
.update({
|
||||
status,
|
||||
completed_at: status === "done" ? new Date().toISOString() : null
|
||||
})
|
||||
.eq("id", task_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ task: data }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Create task
|
||||
if (body.action === "create_task") {
|
||||
const { project_id, title, description, due_date, priority } = body;
|
||||
const { data, error } = await supabase
|
||||
.from("staff_project_tasks")
|
||||
.insert({
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
due_date,
|
||||
priority,
|
||||
assignee_id: userId,
|
||||
status: "todo"
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ task: data }), { status: 201, 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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
60
api/staff/reviews.ts
Normal file
60
api/staff/reviews.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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;
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const { data: reviews, error } = await supabase
|
||||
.from("staff_performance_reviews")
|
||||
.select(`
|
||||
*,
|
||||
reviewer:profiles!staff_performance_reviews_reviewer_id_fkey(full_name, avatar_url)
|
||||
`)
|
||||
.eq("employee_id", userId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const stats = {
|
||||
total: reviews?.length || 0,
|
||||
pending: reviews?.filter(r => r.status === "pending").length || 0,
|
||||
completed: reviews?.filter(r => r.status === "completed").length || 0,
|
||||
average_rating: reviews?.filter(r => r.overall_rating).reduce((sum, r) => sum + r.overall_rating, 0) / (reviews?.filter(r => r.overall_rating).length || 1) || 0
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ reviews: reviews || [], stats }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
const { review_id, employee_comments } = body;
|
||||
|
||||
// Employee can only add their comments
|
||||
const { data, error } = await supabase
|
||||
.from("staff_performance_reviews")
|
||||
.update({ employee_comments })
|
||||
.eq("id", review_id)
|
||||
.eq("employee_id", userId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ review: data }), { 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, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,216 +10,192 @@ import {
|
|||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bell, Star, Archive, Pin } from "lucide-react";
|
||||
import { Bell, Pin, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
author: string;
|
||||
date: string;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
priority: "High" | "Normal" | "Low";
|
||||
priority: string;
|
||||
is_pinned: boolean;
|
||||
is_read: boolean;
|
||||
published_at: string;
|
||||
author?: { full_name: string; avatar_url: string };
|
||||
}
|
||||
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Q1 2025 All-Hands Meeting Rescheduled",
|
||||
content:
|
||||
"The all-hands meeting has been moved to Friday at 2 PM PST. Please mark your calendars and join us for company updates.",
|
||||
category: "Company News",
|
||||
author: "Sarah Chen",
|
||||
date: "Today",
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
priority: "High",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "New Benefits Portal is Live",
|
||||
content:
|
||||
"Welcome to our upgraded benefits portal! You can now view and manage your health insurance, retirement plans, and more.",
|
||||
category: "Benefits",
|
||||
author: "HR Team",
|
||||
date: "2 days ago",
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
priority: "High",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Summer Internship Program Open for Applications",
|
||||
content:
|
||||
"We're hiring summer interns across all departments. If you know someone talented, send them our way!",
|
||||
category: "Hiring",
|
||||
author: "Talent Team",
|
||||
date: "3 days ago",
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
priority: "Normal",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Server Maintenance Window This Weekend",
|
||||
content:
|
||||
"We'll be performing scheduled maintenance on Saturday evening. Services may be temporarily unavailable.",
|
||||
category: "Technical",
|
||||
author: "DevOps Team",
|
||||
date: "4 days ago",
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
priority: "Normal",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Welcome New Team Members!",
|
||||
content:
|
||||
"Please join us in welcoming 5 amazing new colleagues who started this week. Check out their profiles in the directory.",
|
||||
category: "Team",
|
||||
author: "HR Team",
|
||||
date: "1 week ago",
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
priority: "Low",
|
||||
},
|
||||
];
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "High":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
case "Normal":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "Low":
|
||||
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
case "urgent": return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
case "high": return "bg-orange-500/20 text-orange-300 border-orange-500/30";
|
||||
case "normal": return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "low": return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
||||
default: return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
const categories = [
|
||||
"All",
|
||||
"Company News",
|
||||
"Benefits",
|
||||
"Hiring",
|
||||
"Technical",
|
||||
"Team",
|
||||
];
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "urgent": return "bg-red-600";
|
||||
case "policy": return "bg-purple-600";
|
||||
case "event": return "bg-blue-600";
|
||||
case "celebration": return "bg-green-600";
|
||||
default: return "bg-slate-600";
|
||||
}
|
||||
};
|
||||
|
||||
export default function StaffAnnouncements() {
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const { session } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [showRead, setShowRead] = useState(true);
|
||||
|
||||
const filtered = announcements.filter((ann) => {
|
||||
const matchesCategory =
|
||||
selectedCategory === "All" || ann.category === selectedCategory;
|
||||
const matchesArchived = showArchived ? ann.isArchived : !ann.isArchived;
|
||||
return matchesCategory && matchesArchived;
|
||||
useEffect(() => {
|
||||
if (session?.access_token) fetchAnnouncements();
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/announcements", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAnnouncements(data.announcements || []);
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load announcements");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await fetch("/api/staff/announcements", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: "mark_read", id }),
|
||||
});
|
||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, is_read: true } : a));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) return "Today";
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
const categories = ["all", ...new Set(announcements.map(a => a.category))];
|
||||
|
||||
const filtered = announcements.filter(a => {
|
||||
const matchesCategory = selectedCategory === "all" || a.category === selectedCategory;
|
||||
const matchesRead = showRead || !a.is_read;
|
||||
return matchesCategory && matchesRead;
|
||||
});
|
||||
|
||||
const pinnedAnnouncements = filtered.filter((a) => a.isPinned);
|
||||
const unpinnedAnnouncements = filtered.filter((a) => !a.isPinned);
|
||||
const pinned = filtered.filter(a => a.is_pinned);
|
||||
const unpinned = filtered.filter(a => !a.is_pinned);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Announcements" description="Company news and updates" />
|
||||
<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-rose-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Announcements" description="Company news and updates" />
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-rose-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-pink-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Header */}
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 rounded-lg bg-rose-500/20 border border-rose-500/30">
|
||||
<Bell className="h-6 w-6 text-rose-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-rose-100">
|
||||
Announcements
|
||||
</h1>
|
||||
<p className="text-rose-200/70">
|
||||
Company news, updates, and important information
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold text-rose-100">Announcements</h1>
|
||||
<p className="text-rose-200/70">Company news, updates, and important information</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={
|
||||
selectedCategory === category ? "default" : "outline"
|
||||
}
|
||||
key={cat}
|
||||
variant={selectedCategory === cat ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={
|
||||
selectedCategory === category
|
||||
? "bg-rose-600 hover:bg-rose-700"
|
||||
: "border-rose-500/30 text-rose-300 hover:bg-rose-500/10"
|
||||
}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={selectedCategory === cat ? "bg-rose-600 hover:bg-rose-700" : "border-rose-500/30 text-rose-300 hover:bg-rose-500/10"}
|
||||
>
|
||||
{category}
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
onClick={() => setShowRead(!showRead)}
|
||||
className="border-rose-500/30 text-rose-300 hover:bg-rose-500/10"
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
{showArchived ? "Show Active" : "Show Archived"}
|
||||
{showRead ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
|
||||
{showRead ? "Hide Read" : "Show All"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pinned Announcements */}
|
||||
{pinnedAnnouncements.length > 0 && (
|
||||
{pinned.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-lg font-semibold text-rose-100 mb-4 flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned
|
||||
<Pin className="h-5 w-5" /> Pinned
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{pinnedAnnouncements.map((announcement) => (
|
||||
{pinned.map(ann => (
|
||||
<Card
|
||||
key={announcement.id}
|
||||
className="bg-slate-800/50 border-rose-500/50 hover:border-rose-400/80 transition-all"
|
||||
key={ann.id}
|
||||
className={`bg-slate-800/50 border-rose-500/50 hover:border-rose-400/80 transition-all ${!ann.is_read ? "ring-2 ring-rose-500/30" : ""}`}
|
||||
onClick={() => !ann.is_read && markAsRead(ann.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-rose-100">
|
||||
{announcement.title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-rose-100">{ann.title}</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
by {announcement.author} • {announcement.date}
|
||||
by {ann.author?.full_name || "Staff"} • {formatDate(ann.published_at)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
className={`border ${getPriorityColor(announcement.priority)}`}
|
||||
>
|
||||
{announcement.priority}
|
||||
<Badge className={`border ${getPriorityColor(ann.priority)}`}>
|
||||
{ann.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-300 mb-3">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<Badge className="bg-slate-700 text-slate-300">
|
||||
{announcement.category}
|
||||
</Badge>
|
||||
<p className="text-slate-300 mb-3">{ann.content}</p>
|
||||
<Badge className={getCategoryColor(ann.category)}>{ann.category}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -227,42 +203,32 @@ export default function StaffAnnouncements() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular Announcements */}
|
||||
{unpinnedAnnouncements.length > 0 && (
|
||||
{unpinned.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-rose-100 mb-4">
|
||||
Recent Announcements
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-rose-100 mb-4">Recent Announcements</h2>
|
||||
<div className="space-y-4">
|
||||
{unpinnedAnnouncements.map((announcement) => (
|
||||
{unpinned.map(ann => (
|
||||
<Card
|
||||
key={announcement.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-rose-500/50 transition-all"
|
||||
key={ann.id}
|
||||
className={`bg-slate-800/50 border-slate-700/50 hover:border-rose-500/50 transition-all cursor-pointer ${!ann.is_read ? "ring-2 ring-rose-500/30" : ""}`}
|
||||
onClick={() => !ann.is_read && markAsRead(ann.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-rose-100">
|
||||
{announcement.title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-rose-100">{ann.title}</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
by {announcement.author} • {announcement.date}
|
||||
by {ann.author?.full_name || "Staff"} • {formatDate(ann.published_at)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
className={`border ${getPriorityColor(announcement.priority)}`}
|
||||
>
|
||||
{announcement.priority}
|
||||
<Badge className={`border ${getPriorityColor(ann.priority)}`}>
|
||||
{ann.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-300 mb-3">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<Badge className="bg-slate-700 text-slate-300">
|
||||
{announcement.category}
|
||||
</Badge>
|
||||
<p className="text-slate-300 mb-3">{ann.content}</p>
|
||||
<Badge className={getCategoryColor(ann.category)}>{ann.category}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -272,6 +238,7 @@ export default function StaffAnnouncements() {
|
|||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Bell className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-slate-400">No announcements found</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,200 +1,152 @@
|
|||
import { useState } from "react";
|
||||
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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DollarSign, FileText, Calendar, CheckCircle, AlertCircle, Plus, Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Expense {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
date: string;
|
||||
status: "Pending" | "Approved" | "Reimbursed" | "Rejected";
|
||||
receipt: boolean;
|
||||
status: string;
|
||||
receipt_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Budget {
|
||||
category: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
percentage: number;
|
||||
interface Stats {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
reimbursed: number;
|
||||
total_amount: number;
|
||||
pending_amount: number;
|
||||
}
|
||||
|
||||
const expenses: Expense[] = [
|
||||
{
|
||||
id: "1",
|
||||
description: "Conference Registration - GDC 2025",
|
||||
amount: 1200,
|
||||
category: "Training",
|
||||
date: "March 10, 2025",
|
||||
status: "Approved",
|
||||
receipt: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
description: "Laptop Stand and Keyboard",
|
||||
amount: 180,
|
||||
category: "Equipment",
|
||||
date: "March 5, 2025",
|
||||
status: "Reimbursed",
|
||||
receipt: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
description: "Client Dinner Meeting",
|
||||
amount: 85.5,
|
||||
category: "Entertainment",
|
||||
date: "February 28, 2025",
|
||||
status: "Reimbursed",
|
||||
receipt: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
description: "Cloud Services - AWS",
|
||||
amount: 450,
|
||||
category: "Software",
|
||||
date: "February 20, 2025",
|
||||
status: "Pending",
|
||||
receipt: true,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
description: "Travel to NYC Office",
|
||||
amount: 320,
|
||||
category: "Travel",
|
||||
date: "February 15, 2025",
|
||||
status: "Rejected",
|
||||
receipt: true,
|
||||
},
|
||||
];
|
||||
|
||||
const budgets: Budget[] = [
|
||||
{
|
||||
category: "Training & Development",
|
||||
allocated: 5000,
|
||||
spent: 2100,
|
||||
percentage: 42,
|
||||
},
|
||||
{
|
||||
category: "Equipment & Hardware",
|
||||
allocated: 2500,
|
||||
spent: 1850,
|
||||
percentage: 74,
|
||||
},
|
||||
{
|
||||
category: "Travel",
|
||||
allocated: 3000,
|
||||
spent: 2200,
|
||||
percentage: 73,
|
||||
},
|
||||
{
|
||||
category: "Software & Tools",
|
||||
allocated: 1500,
|
||||
spent: 1200,
|
||||
percentage: 80,
|
||||
},
|
||||
{
|
||||
category: "Entertainment & Client Meals",
|
||||
allocated: 1000,
|
||||
spent: 320,
|
||||
percentage: 32,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "Reimbursed":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "Approved":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "Pending":
|
||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
case "Rejected":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
case "reimbursed": return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "approved": return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "pending": return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
case "rejected": return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
default: return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 80) return "bg-red-500";
|
||||
if (percentage >= 60) return "bg-amber-500";
|
||||
return "bg-green-500";
|
||||
};
|
||||
const categories = ["travel", "equipment", "software", "meals", "office", "training", "other"];
|
||||
|
||||
export default function StaffExpenseReports() {
|
||||
const { session } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expenses, setExpenses] = useState<Expense[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string | null>(null);
|
||||
const [showNewDialog, setShowNewDialog] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newExpense, setNewExpense] = useState({ title: "", description: "", amount: "", category: "other", receipt_url: "" });
|
||||
|
||||
const filtered = filterStatus
|
||||
? expenses.filter((e) => e.status === filterStatus)
|
||||
: expenses;
|
||||
useEffect(() => {
|
||||
if (session?.access_token) fetchExpenses();
|
||||
}, [session?.access_token]);
|
||||
|
||||
const totalSpent = expenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
const totalApproved = expenses
|
||||
.filter((e) => e.status === "Approved" || e.status === "Reimbursed")
|
||||
.reduce((sum, e) => sum + e.amount, 0);
|
||||
const fetchExpenses = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/expenses", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExpenses(data.expenses || []);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load expenses");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitExpense = async () => {
|
||||
if (!newExpense.title || !newExpense.amount || !newExpense.category) {
|
||||
aethexToast.error("Please fill in required fields");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/staff/expenses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...newExpense, amount: parseFloat(newExpense.amount) }),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success("Expense submitted!");
|
||||
setShowNewDialog(false);
|
||||
setNewExpense({ title: "", description: "", amount: "", category: "other", receipt_url: "" });
|
||||
fetchExpenses();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to submit expense");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
|
||||
const filtered = filterStatus ? expenses.filter(e => e.status === filterStatus) : expenses;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Expense Reports" description="Reimbursement requests and budget tracking" />
|
||||
<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-green-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO
|
||||
title="Expense Reports"
|
||||
description="Reimbursement requests and budget tracking"
|
||||
/>
|
||||
|
||||
<SEO title="Expense Reports" description="Reimbursement requests and budget tracking" />
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-green-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-emerald-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Header */}
|
||||
<div className="container mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 rounded-lg bg-green-500/20 border border-green-500/30">
|
||||
<DollarSign className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-green-100">
|
||||
Expense Reports
|
||||
</h1>
|
||||
<p className="text-green-200/70">
|
||||
Reimbursement requests and budget tracking
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold text-green-100">Expense Reports</h1>
|
||||
<p className="text-green-200/70">Reimbursement requests and budget tracking</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||
<Card className="bg-green-950/30 border-green-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-200/70">
|
||||
Total Submitted
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-green-100">
|
||||
${totalSpent.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-green-200/70">Total Submitted</p>
|
||||
<p className="text-3xl font-bold text-green-100">${stats?.total_amount?.toFixed(2) || "0.00"}</p>
|
||||
</div>
|
||||
<FileText className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
|
|
@ -205,9 +157,7 @@ export default function StaffExpenseReports() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-200/70">Approved</p>
|
||||
<p className="text-3xl font-bold text-green-100">
|
||||
${totalApproved.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-green-100">{stats?.approved || 0} reports</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
|
|
@ -217,143 +167,139 @@ export default function StaffExpenseReports() {
|
|||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-200/70">Pending</p>
|
||||
<p className="text-3xl font-bold text-green-100">
|
||||
$
|
||||
{expenses
|
||||
.filter((e) => e.status === "Pending")
|
||||
.reduce((sum, e) => sum + e.amount, 0)
|
||||
.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-green-200/70">Pending Amount</p>
|
||||
<p className="text-3xl font-bold text-green-100">${stats?.pending_amount?.toFixed(2) || "0.00"}</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-green-400" />
|
||||
<AlertCircle className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Budget Overview */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-green-100 mb-6">
|
||||
Budget Overview
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{budgets.map((budget) => (
|
||||
<Card
|
||||
key={budget.category}
|
||||
className="bg-slate-800/50 border-slate-700/50"
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-green-100">
|
||||
{budget.category}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
${budget.spent.toFixed(2)} of ${budget.allocated}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-300">
|
||||
{budget.percentage}%
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={budget.percentage} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-green-100">Expense Reports</h2>
|
||||
<Button onClick={() => setShowNewDialog(true)} className="bg-green-600 hover:bg-green-700">
|
||||
<Plus className="h-4 w-4 mr-2" /> New Expense
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expense List */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-green-100">
|
||||
Expense Reports
|
||||
</h2>
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Expense
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{[null, "pending", "approved", "reimbursed", "rejected"].map(status => (
|
||||
<Button
|
||||
variant={filterStatus === null ? "default" : "outline"}
|
||||
key={status || "all"}
|
||||
variant={filterStatus === status ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilterStatus(null)}
|
||||
className={
|
||||
filterStatus === null
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "border-green-500/30 text-green-300 hover:bg-green-500/10"
|
||||
}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={filterStatus === status ? "bg-green-600 hover:bg-green-700" : "border-green-500/30 text-green-300 hover:bg-green-500/10"}
|
||||
>
|
||||
All
|
||||
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "All"}
|
||||
</Button>
|
||||
{["Pending", "Approved", "Reimbursed", "Rejected"].map(
|
||||
(status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={filterStatus === status ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={
|
||||
filterStatus === status
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "border-green-500/30 text-green-300 hover:bg-green-500/10"
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expenses */}
|
||||
<div className="space-y-4">
|
||||
{filtered.map((expense) => (
|
||||
<Card
|
||||
key={expense.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-green-500/50 transition-all"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<DollarSign className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-slate-400">No expenses found</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(expense => (
|
||||
<Card key={expense.id} className="bg-slate-800/50 border-slate-700/50 hover:border-green-500/50 transition-all">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-green-100">
|
||||
{expense.description}
|
||||
</p>
|
||||
<p className="font-semibold text-green-100">{expense.title}</p>
|
||||
{expense.description && <p className="text-sm text-slate-400 mt-1">{expense.description}</p>}
|
||||
<div className="flex gap-4 text-sm text-slate-400 mt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{expense.date}
|
||||
{formatDate(expense.created_at)}
|
||||
</span>
|
||||
<Badge className="bg-slate-700 text-slate-300">
|
||||
{expense.category}
|
||||
</Badge>
|
||||
{expense.receipt && (
|
||||
<span className="text-green-400">✓ Receipt</span>
|
||||
)}
|
||||
<Badge className="bg-slate-700 text-slate-300">{expense.category}</Badge>
|
||||
{expense.receipt_url && <span className="text-green-400">✓ Receipt</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-green-100">
|
||||
${expense.amount.toFixed(2)}
|
||||
</p>
|
||||
<Badge
|
||||
className={`border ${getStatusColor(expense.status)} mt-2`}
|
||||
>
|
||||
{expense.status}
|
||||
</Badge>
|
||||
<p className="text-2xl font-bold text-green-100">${expense.amount.toFixed(2)}</p>
|
||||
<Badge className={`border ${getStatusColor(expense.status)} mt-2`}>{expense.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showNewDialog} onOpenChange={setShowNewDialog}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-green-100">Submit New Expense</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-200">Title *</Label>
|
||||
<Input
|
||||
value={newExpense.title}
|
||||
onChange={e => setNewExpense(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Conference registration"
|
||||
className="bg-slate-700/50 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-200">Amount *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newExpense.amount}
|
||||
onChange={e => setNewExpense(prev => ({ ...prev, amount: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="bg-slate-700/50 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-200">Category *</Label>
|
||||
<Select value={newExpense.category} onValueChange={v => setNewExpense(prev => ({ ...prev, category: v }))}>
|
||||
<SelectTrigger className="bg-slate-700/50 border-slate-600 text-slate-100">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => <SelectItem key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-200">Description</Label>
|
||||
<Textarea
|
||||
value={newExpense.description}
|
||||
onChange={e => setNewExpense(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Additional details..."
|
||||
className="bg-slate-700/50 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-200">Receipt URL</Label>
|
||||
<Input
|
||||
value={newExpense.receipt_url}
|
||||
onChange={e => setNewExpense(prev => ({ ...prev, receipt_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
className="bg-slate-700/50 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewDialog(false)} className="border-slate-600 text-slate-300">Cancel</Button>
|
||||
<Button onClick={submitExpense} disabled={submitting} className="bg-green-600 hover:bg-green-700">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
Submit Expense
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
255
supabase/migrations/20260126_add_staff_features.sql
Normal file
255
supabase/migrations/20260126_add_staff_features.sql
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
-- Staff Feature Tables
|
||||
-- Comprehensive schema for staff management features
|
||||
|
||||
-- Staff Announcements
|
||||
CREATE TABLE IF NOT EXISTS staff_announcements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT DEFAULT 'general' CHECK (category IN ('general', 'policy', 'event', 'urgent', 'celebration')),
|
||||
priority TEXT DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
|
||||
author_id UUID REFERENCES auth.users(id),
|
||||
published_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
read_by UUID[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_announcements_published ON staff_announcements(published_at DESC);
|
||||
CREATE INDEX idx_staff_announcements_category ON staff_announcements(category);
|
||||
|
||||
-- Staff Expense Reports
|
||||
CREATE TABLE IF NOT EXISTS staff_expense_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
category TEXT NOT NULL CHECK (category IN ('travel', 'equipment', 'software', 'meals', 'office', 'training', 'other')),
|
||||
receipt_url TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('draft', 'pending', 'approved', 'rejected', 'reimbursed')),
|
||||
submitted_at TIMESTAMPTZ,
|
||||
reviewed_by UUID REFERENCES auth.users(id),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
review_notes TEXT,
|
||||
reimbursed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_expenses_user ON staff_expense_reports(user_id);
|
||||
CREATE INDEX idx_staff_expenses_status ON staff_expense_reports(status);
|
||||
|
||||
-- Staff Learning/Courses
|
||||
CREATE TABLE IF NOT EXISTS staff_courses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
instructor TEXT,
|
||||
category TEXT NOT NULL,
|
||||
duration_weeks INTEGER DEFAULT 1,
|
||||
lessons_count INTEGER DEFAULT 1,
|
||||
thumbnail_url TEXT,
|
||||
is_required BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_course_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
course_id UUID REFERENCES staff_courses(id) ON DELETE CASCADE,
|
||||
progress_percent INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'available' CHECK (status IN ('available', 'in_progress', 'completed')),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, course_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_course_progress_user ON staff_course_progress(user_id);
|
||||
|
||||
-- Staff Performance Reviews
|
||||
CREATE TABLE IF NOT EXISTS staff_performance_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
employee_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
reviewer_id UUID REFERENCES auth.users(id),
|
||||
review_period TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'submitted', 'completed')),
|
||||
overall_rating INTEGER CHECK (overall_rating >= 1 AND overall_rating <= 5),
|
||||
goals_rating INTEGER CHECK (goals_rating >= 1 AND goals_rating <= 5),
|
||||
collaboration_rating INTEGER CHECK (collaboration_rating >= 1 AND collaboration_rating <= 5),
|
||||
technical_rating INTEGER CHECK (technical_rating >= 1 AND technical_rating <= 5),
|
||||
strengths TEXT,
|
||||
improvements TEXT,
|
||||
goals_next_period TEXT,
|
||||
employee_comments TEXT,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_reviews_employee ON staff_performance_reviews(employee_id);
|
||||
CREATE INDEX idx_staff_reviews_status ON staff_performance_reviews(status);
|
||||
|
||||
-- Staff Knowledge Base
|
||||
CREATE TABLE IF NOT EXISTS staff_knowledge_articles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
author_id UUID REFERENCES auth.users(id),
|
||||
views INTEGER DEFAULT 0,
|
||||
helpful_count INTEGER DEFAULT 0,
|
||||
is_published BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_kb_category ON staff_knowledge_articles(category);
|
||||
CREATE INDEX idx_staff_kb_tags ON staff_knowledge_articles USING GIN(tags);
|
||||
|
||||
-- Staff Projects
|
||||
CREATE TABLE IF NOT EXISTS staff_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'active' CHECK (status IN ('planning', 'active', 'on_hold', 'completed', 'cancelled')),
|
||||
priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'critical')),
|
||||
lead_id UUID REFERENCES auth.users(id),
|
||||
start_date DATE,
|
||||
target_date DATE,
|
||||
progress_percent INTEGER DEFAULT 0,
|
||||
team_members UUID[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_project_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES staff_projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
assignee_id UUID REFERENCES auth.users(id),
|
||||
status TEXT DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date DATE,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_projects_status ON staff_projects(status);
|
||||
CREATE INDEX idx_staff_tasks_project ON staff_project_tasks(project_id);
|
||||
|
||||
-- Staff Internal Marketplace (perks, swag, requests)
|
||||
CREATE TABLE IF NOT EXISTS staff_marketplace_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL CHECK (category IN ('swag', 'equipment', 'perk', 'service')),
|
||||
points_cost INTEGER DEFAULT 0,
|
||||
stock_count INTEGER,
|
||||
image_url TEXT,
|
||||
is_available BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_marketplace_orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
item_id UUID REFERENCES staff_marketplace_items(id),
|
||||
quantity INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'shipped', 'delivered', 'cancelled')),
|
||||
shipping_address TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
|
||||
balance INTEGER DEFAULT 0,
|
||||
lifetime_earned INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Staff Handbook Sections
|
||||
CREATE TABLE IF NOT EXISTS staff_handbook_sections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
last_updated_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_staff_handbook_category ON staff_handbook_sections(category);
|
||||
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE staff_announcements ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_expense_reports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_courses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_course_progress ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_performance_reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_knowledge_articles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_projects ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_project_tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_marketplace_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_marketplace_orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_points ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_handbook_sections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies (staff can view all, edit own where applicable)
|
||||
CREATE POLICY "Staff can view announcements" ON staff_announcements FOR SELECT USING (true);
|
||||
CREATE POLICY "Staff can view own expenses" ON staff_expense_reports FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can insert own expenses" ON staff_expense_reports FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can update own expenses" ON staff_expense_reports FOR UPDATE USING (auth.uid() = user_id AND status IN ('draft', 'pending'));
|
||||
CREATE POLICY "Staff can view courses" ON staff_courses FOR SELECT USING (true);
|
||||
CREATE POLICY "Staff can view own course progress" ON staff_course_progress FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can update own course progress" ON staff_course_progress FOR ALL USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can view own reviews" ON staff_performance_reviews FOR SELECT USING (auth.uid() = employee_id OR auth.uid() = reviewer_id);
|
||||
CREATE POLICY "Staff can view knowledge base" ON staff_knowledge_articles FOR SELECT USING (is_published = true);
|
||||
CREATE POLICY "Staff can view projects" ON staff_projects FOR SELECT USING (auth.uid() = ANY(team_members) OR auth.uid() = lead_id);
|
||||
CREATE POLICY "Staff can view project tasks" ON staff_project_tasks FOR SELECT USING (true);
|
||||
CREATE POLICY "Staff can view marketplace items" ON staff_marketplace_items FOR SELECT USING (is_available = true);
|
||||
CREATE POLICY "Staff can view own orders" ON staff_marketplace_orders FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can create orders" ON staff_marketplace_orders FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can view own points" ON staff_points FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Staff can view handbook" ON staff_handbook_sections FOR SELECT USING (true);
|
||||
|
||||
-- Seed some initial data for courses
|
||||
INSERT INTO staff_courses (title, description, instructor, category, duration_weeks, lessons_count, is_required) VALUES
|
||||
('Advanced TypeScript Patterns', 'Master TypeScript with advanced patterns and best practices', 'Sarah Chen', 'Development', 4, 12, false),
|
||||
('Leadership Fundamentals', 'Core leadership skills for team leads and managers', 'Marcus Johnson', 'Leadership', 6, 15, false),
|
||||
('AWS Solutions Architect', 'Prepare for AWS certification with hands-on labs', 'David Lee', 'Infrastructure', 8, 20, false),
|
||||
('Product Management Essentials', 'Learn the fundamentals of product management', 'Elena Rodriguez', 'Product', 5, 14, false),
|
||||
('Security Best Practices', 'Essential security knowledge for all developers', 'Alex Kim', 'Security', 3, 10, true),
|
||||
('Effective Communication', 'Improve your professional communication skills', 'Patricia Martinez', 'Skills', 2, 8, false);
|
||||
|
||||
-- Seed handbook sections
|
||||
INSERT INTO staff_handbook_sections (title, content, category, order_index) VALUES
|
||||
('Welcome to AeThex', 'Welcome to the team! This handbook contains everything you need to know about working at AeThex.', 'Getting Started', 1),
|
||||
('Our Mission & Values', 'AeThex is dedicated to empowering game developers through innovative tools and community support.', 'Getting Started', 2),
|
||||
('Code of Conduct', 'We maintain a professional, inclusive, and respectful workplace. All team members are expected to treat others with dignity.', 'Policies', 1),
|
||||
('Time Off & Leave', 'Full-time employees receive unlimited PTO with manager approval. Please provide reasonable notice for planned time off.', 'Policies', 2),
|
||||
('Remote Work Policy', 'AeThex is a remote-first company. You are free to work from anywhere as long as you maintain communication with your team.', 'Policies', 3),
|
||||
('Communication Guidelines', 'We use Discord for real-time communication and Linear for project management. Check messages during your working hours.', 'Operations', 1),
|
||||
('Development Workflow', 'All code changes go through pull request review. Follow our coding standards documented in the repository.', 'Operations', 2),
|
||||
('Benefits Overview', 'Full-time employees receive health insurance, 401k matching, equipment stipend, and professional development budget.', 'Benefits', 1);
|
||||
|
||||
-- Seed marketplace items
|
||||
INSERT INTO staff_marketplace_items (name, description, category, points_cost, stock_count, is_available) VALUES
|
||||
('AeThex Hoodie', 'Comfortable hoodie with embroidered AeThex logo', 'swag', 500, 50, true),
|
||||
('Mechanical Keyboard', 'High-quality mechanical keyboard for developers', 'equipment', 1500, 10, true),
|
||||
('Extra Monitor', '27" 4K monitor for your home office', 'equipment', 3000, 5, true),
|
||||
('Coffee Subscription', 'Monthly premium coffee delivery', 'perk', 200, null, true),
|
||||
('Learning Budget Boost', 'Extra $500 for courses and conferences', 'perk', 1000, null, true),
|
||||
('AeThex Sticker Pack', 'Set of 10 vinyl stickers', 'swag', 100, 100, true);
|
||||
Loading…
Reference in a new issue