Build complete Staff Onboarding Portal

- Add StaffOnboarding.tsx main hub with welcome banner, progress ring,
  and quick action cards
- Add StaffOnboardingChecklist.tsx with interactive Day 1/Week 1/Month 1
  checklist that saves progress to database
- Add database migration for staff_onboarding_progress and
  staff_onboarding_metadata tables with RLS policies
- Add API endpoint /api/staff/onboarding for fetching and updating
  onboarding progress with admin view for managers
- Add routes to App.tsx for /staff/onboarding and /staff/onboarding/checklist
This commit is contained in:
Claude 2026-01-26 21:14:44 +00:00
parent 9c3942ebbc
commit 0136d3d8a4
No known key found for this signature in database
5 changed files with 1375 additions and 0 deletions

289
api/staff/onboarding.ts Normal file
View file

@ -0,0 +1,289 @@
import { supabase } from "../_supabase.js";
interface ChecklistItem {
id: string;
checklist_item: string;
phase: string;
completed: boolean;
completed_at: string | null;
notes: string | null;
}
interface OnboardingMetadata {
start_date: string;
manager_id: string | null;
department: string | null;
role_title: string | null;
onboarding_completed: boolean;
}
// Default checklist items for new staff
const DEFAULT_CHECKLIST_ITEMS = [
// Day 1
{ item: "Complete HR paperwork", phase: "day1" },
{ item: "Set up workstation", phase: "day1" },
{ item: "Join Discord server", phase: "day1" },
{ item: "Meet your manager", phase: "day1" },
{ item: "Review company handbook", phase: "day1" },
{ item: "Set up email and accounts", phase: "day1" },
// Week 1
{ item: "Complete security training", phase: "week1" },
{ item: "Set up development environment", phase: "week1" },
{ item: "Review codebase architecture", phase: "week1" },
{ item: "Attend team standup", phase: "week1" },
{ item: "Complete first small task", phase: "week1" },
{ item: "Meet team members", phase: "week1" },
// Month 1
{ item: "Complete onboarding course", phase: "month1" },
{ item: "Contribute to first sprint", phase: "month1" },
{ item: "30-day check-in with manager", phase: "month1" },
{ item: "Set Q1 OKRs", phase: "month1" },
{ item: "Shadow a senior team member", phase: "month1" },
];
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch onboarding progress
if (req.method === "GET") {
// Check for admin view (managers viewing team progress)
if (url.pathname.endsWith("/admin")) {
// Get team members for this manager
const { data: teamMembers, error: teamError } = await supabase
.from("staff_members")
.select("user_id, full_name, email, avatar_url, start_date")
.eq("manager_id", userId);
if (teamError) {
return new Response(JSON.stringify({ error: teamError.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
if (!teamMembers || teamMembers.length === 0) {
return new Response(JSON.stringify({ team: [] }), {
headers: { "Content-Type": "application/json" },
});
}
// Get progress for all team members
const userIds = teamMembers.map((m) => m.user_id);
const { data: progressData } = await supabase
.from("staff_onboarding_progress")
.select("*")
.in("user_id", userIds);
// Calculate completion for each team member
const teamProgress = teamMembers.map((member) => {
const memberProgress = progressData?.filter(
(p) => p.user_id === member.user_id,
);
const completed =
memberProgress?.filter((p) => p.completed).length || 0;
const total = DEFAULT_CHECKLIST_ITEMS.length;
return {
...member,
progress_completed: completed,
progress_total: total,
progress_percentage: Math.round((completed / total) * 100),
};
});
return new Response(JSON.stringify({ team: teamProgress }), {
headers: { "Content-Type": "application/json" },
});
}
// Regular user view - get own progress
const { data: progress, error: progressError } = await supabase
.from("staff_onboarding_progress")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: true });
// Get or create metadata
let { data: metadata, error: metadataError } = await supabase
.from("staff_onboarding_metadata")
.select("*")
.eq("user_id", userId)
.single();
// If no metadata exists, create it
if (!metadata && metadataError?.code === "PGRST116") {
const { data: newMetadata } = await supabase
.from("staff_onboarding_metadata")
.insert({ user_id: userId })
.select()
.single();
metadata = newMetadata;
}
// Get staff member info for name/department
const { data: staffMember } = await supabase
.from("staff_members")
.select("full_name, department, role, avatar_url")
.eq("user_id", userId)
.single();
// Get manager info if exists
let managerInfo = null;
if (metadata?.manager_id) {
const { data: manager } = await supabase
.from("staff_members")
.select("full_name, email, avatar_url")
.eq("user_id", metadata.manager_id)
.single();
managerInfo = manager;
}
// If no progress exists, initialize with default items
let progressItems = progress || [];
if (!progress || progress.length === 0) {
const itemsToInsert = DEFAULT_CHECKLIST_ITEMS.map((item) => ({
user_id: userId,
checklist_item: item.item,
phase: item.phase,
completed: false,
}));
const { data: insertedItems } = await supabase
.from("staff_onboarding_progress")
.insert(itemsToInsert)
.select();
progressItems = insertedItems || [];
}
// Group by phase
const groupedProgress = {
day1: progressItems.filter((p) => p.phase === "day1"),
week1: progressItems.filter((p) => p.phase === "week1"),
month1: progressItems.filter((p) => p.phase === "month1"),
};
// Calculate overall progress
const completed = progressItems.filter((p) => p.completed).length;
const total = progressItems.length;
return new Response(
JSON.stringify({
progress: groupedProgress,
metadata: metadata || { start_date: new Date().toISOString() },
staff_member: staffMember,
manager: managerInfo,
summary: {
completed,
total,
percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
},
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Mark item complete/incomplete
if (req.method === "POST") {
const body = await req.json();
const { checklist_item, completed, notes } = body;
if (!checklist_item) {
return new Response(
JSON.stringify({ error: "checklist_item is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
// Upsert the progress item
const { data, error } = await supabase
.from("staff_onboarding_progress")
.upsert(
{
user_id: userId,
checklist_item,
phase:
DEFAULT_CHECKLIST_ITEMS.find((i) => i.item === checklist_item)
?.phase || "day1",
completed: completed ?? true,
completed_at: completed ? new Date().toISOString() : null,
notes: notes || null,
},
{
onConflict: "user_id,checklist_item",
},
)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Check if all items are complete
const { data: allProgress } = await supabase
.from("staff_onboarding_progress")
.select("completed")
.eq("user_id", userId);
const allCompleted = allProgress?.every((p) => p.completed);
// Update metadata if all completed
if (allCompleted) {
await supabase
.from("staff_onboarding_metadata")
.update({
onboarding_completed: true,
onboarding_completed_at: new Date().toISOString(),
})
.eq("user_id", userId);
}
return new Response(
JSON.stringify({
item: data,
all_completed: allCompleted,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Onboarding API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -159,6 +159,8 @@ import StaffLearningPortal from "./pages/staff/StaffLearningPortal";
import StaffPerformanceReviews from "./pages/staff/StaffPerformanceReviews";
import StaffProjectTracking from "./pages/staff/StaffProjectTracking";
import StaffTeamHandbook from "./pages/staff/StaffTeamHandbook";
import StaffOnboarding from "./pages/staff/StaffOnboarding";
import StaffOnboardingChecklist from "./pages/staff/StaffOnboardingChecklist";
const queryClient = new QueryClient();
@ -412,6 +414,24 @@ const App = () => (
}
/>
{/* Staff Onboarding Routes */}
<Route
path="/staff/onboarding"
element={
<RequireAccess>
<StaffOnboarding />
</RequireAccess>
}
/>
<Route
path="/staff/onboarding/checklist"
element={
<RequireAccess>
<StaffOnboardingChecklist />
</RequireAccess>
}
/>
{/* Staff Management Routes */}
<Route
path="/staff/directory"

View file

@ -0,0 +1,515 @@
import { useState, useEffect } from "react";
import { Link } from "wouter";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Rocket,
CheckCircle2,
Clock,
Users,
BookOpen,
MessageSquare,
Calendar,
ArrowRight,
Sparkles,
Target,
Coffee,
Loader2,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface OnboardingData {
progress: {
day1: ChecklistItem[];
week1: ChecklistItem[];
month1: ChecklistItem[];
};
metadata: {
start_date: string;
manager_id: string | null;
department: string | null;
role_title: string | null;
onboarding_completed: boolean;
};
staff_member: {
full_name: string;
department: string;
role: string;
avatar_url: string | null;
} | null;
manager: {
full_name: string;
email: string;
avatar_url: string | null;
} | null;
summary: {
completed: number;
total: number;
percentage: number;
};
}
interface ChecklistItem {
id: string;
checklist_item: string;
phase: string;
completed: boolean;
completed_at: string | null;
}
export default function StaffOnboarding() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<OnboardingData | null>(null);
useEffect(() => {
if (session?.access_token) {
fetchOnboardingData();
}
}, [session?.access_token]);
const fetchOnboardingData = async () => {
try {
const response = await fetch("/api/staff/onboarding", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
if (!response.ok) throw new Error("Failed to fetch onboarding data");
const result = await response.json();
setData(result);
} catch (error) {
console.error("Error fetching onboarding:", error);
aethexToast.error("Failed to load onboarding data");
} finally {
setLoading(false);
}
};
const getCurrentPhase = () => {
if (!data) return "day1";
const { day1, week1 } = data.progress;
const day1Complete = day1.every((item) => item.completed);
const week1Complete = week1.every((item) => item.completed);
if (!day1Complete) return "day1";
if (!week1Complete) return "week1";
return "month1";
};
const getPhaseLabel = (phase: string) => {
switch (phase) {
case "day1":
return "Day 1";
case "week1":
return "Week 1";
case "month1":
return "Month 1";
default:
return phase;
}
};
const getDaysSinceStart = () => {
if (!data?.metadata?.start_date) return 0;
const start = new Date(data.metadata.start_date);
const now = new Date();
const diff = Math.floor(
(now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24),
);
return diff;
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
if (loading) {
return (
<Layout>
<SEO
title="Staff Onboarding"
description="Welcome to AeThex - Your onboarding journey"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
</div>
</Layout>
);
}
const currentPhase = getCurrentPhase();
const daysSinceStart = getDaysSinceStart();
return (
<Layout>
<SEO
title="Staff Onboarding"
description="Welcome to AeThex - Your onboarding journey"
/>
<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-emerald-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-teal-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-6xl px-4 py-16">
{/* Welcome Header */}
<div className="mb-12">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-lg bg-emerald-500/20 border border-emerald-500/30">
<Rocket className="h-6 w-6 text-emerald-400" />
</div>
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-500/30">
{currentPhase === "day1"
? "Getting Started"
: currentPhase === "week1"
? "Week 1"
: "Month 1"}
</Badge>
</div>
<h1 className="text-4xl font-bold text-emerald-100 mb-2">
Welcome to AeThex
{data?.staff_member?.full_name
? `, ${data.staff_member.full_name.split(" ")[0]}!`
: "!"}
</h1>
<p className="text-emerald-200/70 text-lg">
{data?.summary?.percentage === 100
? "Congratulations! You've completed your onboarding journey."
: `Day ${daysSinceStart + 1} of your onboarding journey. Let's make it great!`}
</p>
</div>
{/* Progress Overview */}
<Card className="bg-slate-800/50 border-emerald-500/30 mb-8">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
{/* Progress Ring */}
<div className="flex items-center gap-6">
<div className="relative w-24 h-24">
<svg className="w-24 h-24 transform -rotate-90">
<circle
className="text-slate-700"
strokeWidth="8"
stroke="currentColor"
fill="transparent"
r="40"
cx="48"
cy="48"
/>
<circle
className="text-emerald-500"
strokeWidth="8"
strokeDasharray={251.2}
strokeDashoffset={
251.2 - (251.2 * (data?.summary?.percentage || 0)) / 100
}
strokeLinecap="round"
stroke="currentColor"
fill="transparent"
r="40"
cx="48"
cy="48"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold text-emerald-100">
{data?.summary?.percentage || 0}%
</span>
</div>
<div>
<p className="text-emerald-100 font-semibold text-lg">
Onboarding Progress
</p>
<p className="text-slate-400">
{data?.summary?.completed || 0} of{" "}
{data?.summary?.total || 0} tasks completed
</p>
</div>
</div>
{/* Phase Progress */}
<div className="flex gap-4">
{["day1", "week1", "month1"].map((phase) => {
const items = data?.progress?.[phase as keyof typeof data.progress] || [];
const completed = items.filter((i) => i.completed).length;
const total = items.length;
const isComplete = completed === total && total > 0;
const isCurrent = phase === currentPhase;
return (
<div
key={phase}
className={`text-center p-3 rounded-lg ${
isCurrent
? "bg-emerald-500/20 border border-emerald-500/30"
: isComplete
? "bg-green-500/10 border border-green-500/20"
: "bg-slate-700/30 border border-slate-600/30"
}`}
>
{isComplete ? (
<CheckCircle2 className="h-5 w-5 text-green-400 mx-auto mb-1" />
) : (
<Clock className="h-5 w-5 text-slate-400 mx-auto mb-1" />
)}
<p className="text-sm font-medium text-emerald-100">
{getPhaseLabel(phase)}
</p>
<p className="text-xs text-slate-400">
{completed}/{total}
</p>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
{/* Quick Actions Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Link href="/staff/onboarding/checklist">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-emerald-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-emerald-500/20 text-emerald-400 w-fit mb-3">
<CheckCircle2 className="h-5 w-5" />
</div>
<h3 className="font-semibold text-emerald-100 mb-1">
Complete Checklist
</h3>
<p className="text-sm text-slate-400">
Track your onboarding tasks
</p>
</CardContent>
</Card>
</Link>
<Link href="/staff/directory">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-emerald-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-blue-500/20 text-blue-400 w-fit mb-3">
<Users className="h-5 w-5" />
</div>
<h3 className="font-semibold text-emerald-100 mb-1">
Meet Your Team
</h3>
<p className="text-sm text-slate-400">
Browse the staff directory
</p>
</CardContent>
</Card>
</Link>
<Link href="/staff/learning">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-emerald-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-purple-500/20 text-purple-400 w-fit mb-3">
<BookOpen className="h-5 w-5" />
</div>
<h3 className="font-semibold text-emerald-100 mb-1">
Learning Portal
</h3>
<p className="text-sm text-slate-400">
Training courses & resources
</p>
</CardContent>
</Card>
</Link>
<Link href="/staff/handbook">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-emerald-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-orange-500/20 text-orange-400 w-fit mb-3">
<Target className="h-5 w-5" />
</div>
<h3 className="font-semibold text-emerald-100 mb-1">
Team Handbook
</h3>
<p className="text-sm text-slate-400">
Policies & guidelines
</p>
</CardContent>
</Card>
</Link>
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Current Phase Tasks */}
<div className="lg:col-span-2">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-emerald-100 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-400" />
Current Tasks - {getPhaseLabel(currentPhase)}
</CardTitle>
<CardDescription className="text-slate-400">
Focus on completing these tasks first
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data?.progress?.[currentPhase as keyof typeof data.progress]
?.slice(0, 5)
.map((item) => (
<div
key={item.id}
className={`flex items-center gap-3 p-3 rounded-lg ${
item.completed
? "bg-green-500/10 border border-green-500/20"
: "bg-slate-700/30 border border-slate-600/30"
}`}
>
{item.completed ? (
<CheckCircle2 className="h-5 w-5 text-green-400 flex-shrink-0" />
) : (
<div className="h-5 w-5 rounded-full border-2 border-slate-500 flex-shrink-0" />
)}
<span
className={
item.completed
? "text-slate-400 line-through"
: "text-emerald-100"
}
>
{item.checklist_item}
</span>
</div>
))}
</div>
<Link href="/staff/onboarding/checklist">
<Button className="w-full mt-4 bg-emerald-600 hover:bg-emerald-700">
View Full Checklist
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Manager Card */}
{data?.manager && (
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-emerald-100 text-lg flex items-center gap-2">
<Coffee className="h-4 w-4 text-emerald-400" />
Your Manager
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={data.manager.avatar_url || ""} />
<AvatarFallback className="bg-emerald-500/20 text-emerald-300">
{getInitials(data.manager.full_name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-emerald-100">
{data.manager.full_name}
</p>
<p className="text-sm text-slate-400">
{data.manager.email}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full mt-4 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10"
>
<MessageSquare className="h-4 w-4 mr-2" />
Send Message
</Button>
</CardContent>
</Card>
)}
{/* Important Links */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-emerald-100 text-lg">
Quick Links
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<a
href="https://discord.gg/aethex"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2 rounded hover:bg-slate-700/50 text-slate-300 hover:text-emerald-300 transition-colors"
>
<MessageSquare className="h-4 w-4" />
Join Discord Server
</a>
<Link
href="/staff/knowledge-base"
className="flex items-center gap-2 p-2 rounded hover:bg-slate-700/50 text-slate-300 hover:text-emerald-300 transition-colors"
>
<BookOpen className="h-4 w-4" />
Knowledge Base
</Link>
<Link
href="/documentation"
className="flex items-center gap-2 p-2 rounded hover:bg-slate-700/50 text-slate-300 hover:text-emerald-300 transition-colors"
>
<Target className="h-4 w-4" />
Documentation
</Link>
<Link
href="/staff/announcements"
className="flex items-center gap-2 p-2 rounded hover:bg-slate-700/50 text-slate-300 hover:text-emerald-300 transition-colors"
>
<Calendar className="h-4 w-4" />
Announcements
</Link>
</CardContent>
</Card>
{/* Achievement */}
{data?.summary?.percentage === 100 && (
<Card className="bg-gradient-to-br from-emerald-500/20 to-teal-500/20 border-emerald-500/30">
<CardContent className="pt-6 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center mx-auto mb-4">
<Sparkles className="h-8 w-8 text-emerald-400" />
</div>
<h3 className="font-bold text-emerald-100 text-lg mb-1">
Onboarding Complete!
</h3>
<p className="text-sm text-emerald-200/70">
You've completed all onboarding tasks. Welcome to the
team!
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,454 @@
import { useState, useEffect } from "react";
import { Link } from "wouter";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
import {
ClipboardCheck,
CheckCircle2,
Circle,
ArrowLeft,
Loader2,
Calendar,
Clock,
Trophy,
Sun,
Briefcase,
Target,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface ChecklistItem {
id: string;
checklist_item: string;
phase: string;
completed: boolean;
completed_at: string | null;
notes: string | null;
}
interface OnboardingData {
progress: {
day1: ChecklistItem[];
week1: ChecklistItem[];
month1: ChecklistItem[];
};
metadata: {
start_date: string;
onboarding_completed: boolean;
};
summary: {
completed: number;
total: number;
percentage: number;
};
}
const PHASE_INFO = {
day1: {
label: "Day 1",
icon: Sun,
description: "First day essentials - get set up and meet the team",
color: "emerald",
},
week1: {
label: "Week 1",
icon: Briefcase,
description: "Dive into tools, processes, and your first tasks",
color: "blue",
},
month1: {
label: "Month 1",
icon: Target,
description: "Build momentum and complete your onboarding journey",
color: "purple",
},
};
export default function StaffOnboardingChecklist() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [data, setData] = useState<OnboardingData | null>(null);
const [activeTab, setActiveTab] = useState("day1");
useEffect(() => {
if (session?.access_token) {
fetchOnboardingData();
}
}, [session?.access_token]);
const fetchOnboardingData = async () => {
try {
const response = await fetch("/api/staff/onboarding", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
if (!response.ok) throw new Error("Failed to fetch onboarding data");
const result = await response.json();
setData(result);
// Set active tab to current phase
const day1Complete = result.progress.day1.every(
(i: ChecklistItem) => i.completed,
);
const week1Complete = result.progress.week1.every(
(i: ChecklistItem) => i.completed,
);
if (!day1Complete) setActiveTab("day1");
else if (!week1Complete) setActiveTab("week1");
else setActiveTab("month1");
} catch (error) {
console.error("Error fetching onboarding:", error);
aethexToast.error("Failed to load onboarding data");
} finally {
setLoading(false);
}
};
const toggleItem = async (item: ChecklistItem) => {
if (!session?.access_token) return;
setSaving(item.id);
try {
const response = await fetch("/api/staff/onboarding", {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
checklist_item: item.checklist_item,
completed: !item.completed,
}),
});
if (!response.ok) throw new Error("Failed to update item");
const result = await response.json();
// Update local state
if (data) {
const phase = item.phase as keyof typeof data.progress;
const updatedItems = data.progress[phase].map((i) =>
i.id === item.id
? { ...i, completed: !item.completed, completed_at: !item.completed ? new Date().toISOString() : null }
: i,
);
const newCompleted = Object.values({
...data.progress,
[phase]: updatedItems,
}).flat().filter((i) => i.completed).length;
setData({
...data,
progress: {
...data.progress,
[phase]: updatedItems,
},
summary: {
...data.summary,
completed: newCompleted,
percentage: Math.round((newCompleted / data.summary.total) * 100),
},
});
}
if (result.all_completed) {
aethexToast.success(
"Congratulations! You've completed all onboarding tasks!",
);
} else if (!item.completed) {
aethexToast.success("Task completed!");
}
} catch (error) {
console.error("Error updating item:", error);
aethexToast.error("Failed to update task");
} finally {
setSaving(null);
}
};
const getPhaseProgress = (phase: keyof typeof data.progress) => {
if (!data) return { completed: 0, total: 0, percentage: 0 };
const items = data.progress[phase];
const completed = items.filter((i) => i.completed).length;
return {
completed,
total: items.length,
percentage: items.length > 0 ? Math.round((completed / items.length) * 100) : 0,
};
};
const formatDate = (dateString: string | null) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
if (loading) {
return (
<Layout>
<SEO
title="Onboarding Checklist"
description="Track your onboarding progress"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO
title="Onboarding Checklist"
description="Track your onboarding progress"
/>
<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-emerald-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-teal-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-4xl px-4 py-16">
{/* Header */}
<div className="mb-8">
<Link href="/staff/onboarding">
<Button
variant="ghost"
size="sm"
className="text-emerald-300 hover:text-emerald-200 hover:bg-emerald-500/10 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Onboarding
</Button>
</Link>
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-lg bg-emerald-500/20 border border-emerald-500/30">
<ClipboardCheck className="h-6 w-6 text-emerald-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-emerald-100">
Onboarding Checklist
</h1>
<p className="text-emerald-200/70">
Track and complete your onboarding tasks
</p>
</div>
</div>
{/* Overall Progress */}
<Card className="bg-slate-800/50 border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-3">
<span className="text-emerald-100 font-medium">
Overall Progress
</span>
<span className="text-emerald-300 font-bold">
{data?.summary?.completed || 0}/{data?.summary?.total || 0}{" "}
tasks ({data?.summary?.percentage || 0}%)
</span>
</div>
<Progress
value={data?.summary?.percentage || 0}
className="h-3"
/>
{data?.summary?.percentage === 100 && (
<div className="flex items-center gap-2 mt-3 text-green-400">
<Trophy className="h-5 w-5" />
<span className="font-medium">
All tasks completed! Welcome to the team!
</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full bg-slate-800/50 border border-slate-700/50 p-1 mb-6">
{(["day1", "week1", "month1"] as const).map((phase) => {
const info = PHASE_INFO[phase];
const progress = getPhaseProgress(phase);
const Icon = info.icon;
return (
<TabsTrigger
key={phase}
value={phase}
className="flex-1 data-[state=active]:bg-emerald-600 data-[state=active]:text-white"
>
<Icon className="h-4 w-4 mr-2" />
{info.label}
{progress.percentage === 100 && (
<CheckCircle2 className="h-4 w-4 ml-2 text-green-400" />
)}
</TabsTrigger>
);
})}
</TabsList>
{(["day1", "week1", "month1"] as const).map((phase) => {
const info = PHASE_INFO[phase];
const progress = getPhaseProgress(phase);
const items = data?.progress[phase] || [];
return (
<TabsContent key={phase} value={phase}>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-emerald-100 flex items-center gap-2">
{info.label}
{progress.percentage === 100 && (
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
Complete
</Badge>
)}
</CardTitle>
<CardDescription className="text-slate-400">
{info.description}
</CardDescription>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-emerald-100">
{progress.percentage}%
</p>
<p className="text-sm text-slate-400">
{progress.completed}/{progress.total} done
</p>
</div>
</div>
<Progress
value={progress.percentage}
className="h-2 mt-2"
/>
</CardHeader>
<CardContent>
<div className="space-y-3">
{items.map((item) => (
<div
key={item.id}
className={`flex items-start gap-4 p-4 rounded-lg transition-all ${
item.completed
? "bg-green-500/10 border border-green-500/20"
: "bg-slate-700/30 border border-slate-600/30 hover:border-emerald-500/30"
}`}
>
<div className="pt-0.5">
{saving === item.id ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-400" />
) : (
<Checkbox
checked={item.completed}
onCheckedChange={() => toggleItem(item)}
className={`h-5 w-5 ${
item.completed
? "border-green-500 bg-green-500 data-[state=checked]:bg-green-500"
: "border-slate-500"
}`}
/>
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`font-medium ${
item.completed
? "text-slate-400 line-through"
: "text-emerald-100"
}`}
>
{item.checklist_item}
</p>
{item.completed && item.completed_at && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Completed {formatDate(item.completed_at)}
</div>
)}
</div>
{item.completed && (
<CheckCircle2 className="h-5 w-5 text-green-400 flex-shrink-0" />
)}
</div>
))}
</div>
{progress.percentage === 100 && (
<div className="mt-6 p-4 rounded-lg bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20 text-center">
<Trophy className="h-8 w-8 text-green-400 mx-auto mb-2" />
<p className="font-medium text-green-300">
{info.label} Complete!
</p>
<p className="text-sm text-slate-400">
Great job completing all {info.label.toLowerCase()}{" "}
tasks
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
{/* Help Section */}
<Card className="mt-6 bg-slate-800/30 border-slate-700/30">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="p-2 rounded bg-blue-500/20 text-blue-400">
<Calendar className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-emerald-100 mb-1">
Need Help?
</h3>
<p className="text-sm text-slate-400">
If you're stuck on any task or need clarification, don't
hesitate to reach out to your manager or team members. You
can also check the{" "}
<Link
href="/staff/knowledge-base"
className="text-emerald-400 hover:underline"
>
Knowledge Base
</Link>{" "}
for detailed guides.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,97 @@
-- Staff Onboarding Progress Table
-- Tracks individual checklist item completion for new staff members
CREATE TABLE IF NOT EXISTS staff_onboarding_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
checklist_item TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('day1', 'week1', 'month1')),
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, checklist_item)
);
-- Create index for faster lookups
CREATE INDEX idx_staff_onboarding_user_id ON staff_onboarding_progress(user_id);
CREATE INDEX idx_staff_onboarding_phase ON staff_onboarding_progress(phase);
-- Enable RLS
ALTER TABLE staff_onboarding_progress ENABLE ROW LEVEL SECURITY;
-- Staff can view and update their own onboarding progress
CREATE POLICY "Staff can view own onboarding progress"
ON staff_onboarding_progress
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Staff can update own onboarding progress"
ON staff_onboarding_progress
FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Staff can insert own onboarding progress"
ON staff_onboarding_progress
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Managers can view team onboarding progress (staff members table has manager info)
CREATE POLICY "Managers can view team onboarding progress"
ON staff_onboarding_progress
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM staff_members sm
WHERE sm.user_id = staff_onboarding_progress.user_id
AND sm.manager_id = auth.uid()
)
);
-- Staff onboarding metadata for start date and manager assignment
CREATE TABLE IF NOT EXISTS staff_onboarding_metadata (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
start_date DATE NOT NULL DEFAULT CURRENT_DATE,
manager_id UUID REFERENCES auth.users(id),
department TEXT,
role_title TEXT,
onboarding_completed BOOLEAN DEFAULT FALSE,
onboarding_completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS for metadata
ALTER TABLE staff_onboarding_metadata ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Staff can view own metadata"
ON staff_onboarding_metadata
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Managers can view team metadata"
ON staff_onboarding_metadata
FOR SELECT
USING (auth.uid() = manager_id);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_staff_onboarding_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers for updated_at
CREATE TRIGGER staff_onboarding_progress_updated_at
BEFORE UPDATE ON staff_onboarding_progress
FOR EACH ROW
EXECUTE FUNCTION update_staff_onboarding_updated_at();
CREATE TRIGGER staff_onboarding_metadata_updated_at
BEFORE UPDATE ON staff_onboarding_metadata
FOR EACH ROW
EXECUTE FUNCTION update_staff_onboarding_updated_at();