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:
Claude 2026-01-26 22:25:14 +00:00
parent 61fb02cd39
commit f1efc97c86
No known key found for this signature in database
11 changed files with 1253 additions and 421 deletions

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

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

View file

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

View file

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

View 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);