From f1efc97c86a1a57c834008587ede53a360177ff4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:25:14 +0000 Subject: [PATCH] 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. --- api/staff/announcements.ts | 62 +++ api/staff/courses.ts | 100 ++++ api/staff/expenses.ts | 96 ++++ api/staff/handbook.ts | 46 ++ api/staff/knowledge-base.ts | 72 +++ api/staff/marketplace.ts | 126 +++++ api/staff/projects.ts | 102 ++++ api/staff/reviews.ts | 60 +++ client/pages/staff/StaffAnnouncements.tsx | 293 +++++------ client/pages/staff/StaffExpenseReports.tsx | 462 ++++++++---------- .../20260126_add_staff_features.sql | 255 ++++++++++ 11 files changed, 1253 insertions(+), 421 deletions(-) create mode 100644 api/staff/announcements.ts create mode 100644 api/staff/courses.ts create mode 100644 api/staff/expenses.ts create mode 100644 api/staff/handbook.ts create mode 100644 api/staff/knowledge-base.ts create mode 100644 api/staff/marketplace.ts create mode 100644 api/staff/projects.ts create mode 100644 api/staff/reviews.ts create mode 100644 supabase/migrations/20260126_add_staff_features.sql diff --git a/api/staff/announcements.ts b/api/staff/announcements.ts new file mode 100644 index 00000000..346fe1a7 --- /dev/null +++ b/api/staff/announcements.ts @@ -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" } }); + } +}; diff --git a/api/staff/courses.ts b/api/staff/courses.ts new file mode 100644 index 00000000..688d48a9 --- /dev/null +++ b/api/staff/courses.ts @@ -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" } }); + } +}; diff --git a/api/staff/expenses.ts b/api/staff/expenses.ts new file mode 100644 index 00000000..a70ef1df --- /dev/null +++ b/api/staff/expenses.ts @@ -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" } }); + } +}; diff --git a/api/staff/handbook.ts b/api/staff/handbook.ts new file mode 100644 index 00000000..cf48cd8c --- /dev/null +++ b/api/staff/handbook.ts @@ -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); + + 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" } }); + } +}; diff --git a/api/staff/knowledge-base.ts b/api/staff/knowledge-base.ts new file mode 100644 index 00000000..5fae4fe9 --- /dev/null +++ b/api/staff/knowledge-base.ts @@ -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" } }); + } +}; diff --git a/api/staff/marketplace.ts b/api/staff/marketplace.ts new file mode 100644 index 00000000..340a16f3 --- /dev/null +++ b/api/staff/marketplace.ts @@ -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" } }); + } +}; diff --git a/api/staff/projects.ts b/api/staff/projects.ts new file mode 100644 index 00000000..8a71b67f --- /dev/null +++ b/api/staff/projects.ts @@ -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" } }); + } +}; diff --git a/api/staff/reviews.ts b/api/staff/reviews.ts new file mode 100644 index 00000000..dc6b85c7 --- /dev/null +++ b/api/staff/reviews.ts @@ -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" } }); + } +}; diff --git a/client/pages/staff/StaffAnnouncements.tsx b/client/pages/staff/StaffAnnouncements.tsx index 06570428..807bdfa8 100644 --- a/client/pages/staff/StaffAnnouncements.tsx +++ b/client/pages/staff/StaffAnnouncements.tsx @@ -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([]); + 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 ( + + +
+ +
+
+ ); + } return ( -
- {/* Background effects */}
- {/* Header */}
-

- Announcements -

-

- Company news, updates, and important information -

+

Announcements

+

Company news, updates, and important information

- {/* Category Filter */}
- {categories.map((category) => ( + {categories.map(cat => ( ))}
- {/* Pinned Announcements */} - {pinnedAnnouncements.length > 0 && ( + {pinned.length > 0 && (

- - Pinned + Pinned

- {pinnedAnnouncements.map((announcement) => ( + {pinned.map(ann => ( !ann.is_read && markAsRead(ann.id)} >
- - {announcement.title} - + {ann.title} - by {announcement.author} • {announcement.date} + by {ann.author?.full_name || "Staff"} • {formatDate(ann.published_at)}
- - {announcement.priority} + + {ann.priority}
-

- {announcement.content} -

- - {announcement.category} - +

{ann.content}

+ {ann.category}
))} @@ -227,42 +203,32 @@ export default function StaffAnnouncements() {
)} - {/* Regular Announcements */} - {unpinnedAnnouncements.length > 0 && ( + {unpinned.length > 0 && (
-

- Recent Announcements -

+

Recent Announcements

- {unpinnedAnnouncements.map((announcement) => ( + {unpinned.map(ann => ( !ann.is_read && markAsRead(ann.id)} >
- - {announcement.title} - + {ann.title} - by {announcement.author} • {announcement.date} + by {ann.author?.full_name || "Staff"} • {formatDate(ann.published_at)}
- - {announcement.priority} + + {ann.priority}
-

- {announcement.content} -

- - {announcement.category} - +

{ann.content}

+ {ann.category}
))} @@ -272,6 +238,7 @@ export default function StaffAnnouncements() { {filtered.length === 0 && (
+

No announcements found

)} diff --git a/client/pages/staff/StaffExpenseReports.tsx b/client/pages/staff/StaffExpenseReports.tsx index 5f09d60f..eb7e2da8 100644 --- a/client/pages/staff/StaffExpenseReports.tsx +++ b/client/pages/staff/StaffExpenseReports.tsx @@ -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([]); + const [stats, setStats] = useState(null); const [filterStatus, setFilterStatus] = useState(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 ( + + +
+ +
+
+ ); + } return ( - - +
- {/* Background effects */}
- {/* Header */}
-

- Expense Reports -

-

- Reimbursement requests and budget tracking -

+

Expense Reports

+

Reimbursement requests and budget tracking

- {/* Summary Cards */}
-

- Total Submitted -

-

- ${totalSpent.toFixed(2)} -

+

Total Submitted

+

${stats?.total_amount?.toFixed(2) || "0.00"}

@@ -205,9 +157,7 @@ export default function StaffExpenseReports() {

Approved

-

- ${totalApproved.toFixed(2)} -

+

{stats?.approved || 0} reports

@@ -217,143 +167,139 @@ export default function StaffExpenseReports() {
-

Pending

-

- $ - {expenses - .filter((e) => e.status === "Pending") - .reduce((sum, e) => sum + e.amount, 0) - .toFixed(2)} -

+

Pending Amount

+

${stats?.pending_amount?.toFixed(2) || "0.00"}

- +
- {/* Budget Overview */} -
-

- Budget Overview -

-
- {budgets.map((budget) => ( - - -
-
-

- {budget.category} -

-

- ${budget.spent.toFixed(2)} of ${budget.allocated} -

-
-

- {budget.percentage}% -

-
- -
-
- ))} -
+
+

Expense Reports

+
- {/* Expense List */} -
-
-

- Expense Reports -

- -
- - {/* Status Filter */} -
+
+ {[null, "pending", "approved", "reimbursed", "rejected"].map(status => ( - {["Pending", "Approved", "Reimbursed", "Rejected"].map( - (status) => ( - - ), - )} -
+ ))} +
- {/* Expenses */} -
- {filtered.map((expense) => ( - +
+ {filtered.length === 0 ? ( +
+ +

No expenses found

+
+ ) : ( + filtered.map(expense => ( +
-

- {expense.description} -

+

{expense.title}

+ {expense.description &&

{expense.description}

}
- {expense.date} + {formatDate(expense.created_at)} - - {expense.category} - - {expense.receipt && ( - ✓ Receipt - )} + {expense.category} + {expense.receipt_url && ✓ Receipt}
-

- ${expense.amount.toFixed(2)} -

- - {expense.status} - +

${expense.amount.toFixed(2)}

+ {expense.status}
- ))} -
+ )) + )}
+ + + + + Submit New Expense + +
+
+ + setNewExpense(prev => ({ ...prev, title: e.target.value }))} + placeholder="Conference registration" + className="bg-slate-700/50 border-slate-600 text-slate-100" + /> +
+
+
+ + setNewExpense(prev => ({ ...prev, amount: e.target.value }))} + placeholder="0.00" + className="bg-slate-700/50 border-slate-600 text-slate-100" + /> +
+
+ + +
+
+
+ +