Wire remaining 6 staff pages to real APIs
- StaffLearningPortal: Fetches courses from /api/staff/courses, supports starting courses and tracking progress - StaffPerformanceReviews: Fetches reviews from /api/staff/reviews, supports adding employee comments with dialog - StaffKnowledgeBase: Fetches articles from /api/staff/knowledge-base, supports search, filtering, view tracking, and helpful marking - StaffProjectTracking: Fetches projects from /api/staff/projects, supports task status updates and creating new tasks - StaffInternalMarketplace: Now points marketplace with /api/staff/marketplace, supports redeeming items with points and viewing orders - StaffTeamHandbook: Fetches handbook sections from /api/staff/handbook, displays grouped by category with expand/collapse All pages now use real Supabase data instead of mock arrays.
This commit is contained in:
parent
f1efc97c86
commit
01026d43cc
6 changed files with 1136 additions and 767 deletions
|
|
@ -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";
|
||||
|
|
@ -13,127 +13,153 @@ import { Badge } from "@/components/ui/badge";
|
|||
import {
|
||||
ShoppingCart,
|
||||
Search,
|
||||
Users,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Gift,
|
||||
Star,
|
||||
Package,
|
||||
Loader2,
|
||||
Coins,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface Service {
|
||||
interface MarketplaceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
category: string;
|
||||
description: string;
|
||||
availability: "Available" | "Booked" | "Coming Soon";
|
||||
turnaround: string;
|
||||
requests: number;
|
||||
category: string;
|
||||
points_cost: number;
|
||||
image_url?: string;
|
||||
stock_count?: number;
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
const services: Service[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Design Consultation",
|
||||
provider: "Design Team",
|
||||
category: "Design",
|
||||
description: "1-on-1 design review and UX guidance for your project",
|
||||
availability: "Available",
|
||||
turnaround: "2 days",
|
||||
requests: 8,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Code Review",
|
||||
provider: "Engineering",
|
||||
category: "Development",
|
||||
description: "Thorough code review with architectural feedback",
|
||||
availability: "Available",
|
||||
turnaround: "1 day",
|
||||
requests: 15,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Security Audit",
|
||||
provider: "Security Team",
|
||||
category: "Security",
|
||||
description: "Comprehensive security review of your application",
|
||||
availability: "Booked",
|
||||
turnaround: "5 days",
|
||||
requests: 4,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Performance Optimization",
|
||||
provider: "DevOps",
|
||||
category: "Infrastructure",
|
||||
description: "Optimize your application performance and scalability",
|
||||
availability: "Available",
|
||||
turnaround: "3 days",
|
||||
requests: 6,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Product Strategy Session",
|
||||
provider: "Product Team",
|
||||
category: "Product",
|
||||
description: "Alignment session on product roadmap and features",
|
||||
availability: "Coming Soon",
|
||||
turnaround: "4 days",
|
||||
requests: 12,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "API Integration Support",
|
||||
provider: "Backend Team",
|
||||
category: "Development",
|
||||
description: "Help integrating with AeThex APIs and services",
|
||||
availability: "Available",
|
||||
turnaround: "2 days",
|
||||
requests: 10,
|
||||
},
|
||||
];
|
||||
interface Order {
|
||||
id: string;
|
||||
quantity: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
item?: {
|
||||
name: string;
|
||||
image_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const getAvailabilityColor = (availability: string) => {
|
||||
switch (availability) {
|
||||
case "Available":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "Booked":
|
||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
case "Coming Soon":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
interface Points {
|
||||
balance: number;
|
||||
lifetime_earned: number;
|
||||
}
|
||||
|
||||
export default function StaffInternalMarketplace() {
|
||||
const { session } = useAuth();
|
||||
const [items, setItems] = useState<MarketplaceItem[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [points, setPoints] = useState<Points>({ balance: 0, lifetime_earned: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orderDialog, setOrderDialog] = useState<MarketplaceItem | null>(null);
|
||||
const [shippingAddress, setShippingAddress] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchMarketplace();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchMarketplace = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/marketplace", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setItems(data.items || []);
|
||||
setOrders(data.orders || []);
|
||||
setPoints(data.points || { balance: 0, lifetime_earned: 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load marketplace");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const placeOrder = async () => {
|
||||
if (!orderDialog) return;
|
||||
try {
|
||||
const res = await fetch("/api/staff/marketplace", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
item_id: orderDialog.id,
|
||||
quantity: 1,
|
||||
shipping_address: shippingAddress,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
aethexToast.success("Order placed successfully!");
|
||||
setOrderDialog(null);
|
||||
setShippingAddress("");
|
||||
fetchMarketplace();
|
||||
} else {
|
||||
aethexToast.error(data.error || "Failed to place order");
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to place order");
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ["All", ...new Set(items.map((i) => i.category))];
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory =
|
||||
selectedCategory === "All" || item.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "shipped":
|
||||
return "bg-green-500/20 text-green-300";
|
||||
case "processing":
|
||||
return "bg-blue-500/20 text-blue-300";
|
||||
case "pending":
|
||||
return "bg-amber-500/20 text-amber-300";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default function StaffInternalMarketplace() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const categories = [
|
||||
"All",
|
||||
"Design",
|
||||
"Development",
|
||||
"Security",
|
||||
"Infrastructure",
|
||||
"Product",
|
||||
];
|
||||
|
||||
const filtered = services.filter((service) => {
|
||||
const matchesSearch =
|
||||
service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
service.provider.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory =
|
||||
selectedCategory === "All" || service.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-amber-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO
|
||||
title="Internal Marketplace"
|
||||
description="Request services from other teams"
|
||||
title="Points Marketplace"
|
||||
description="Redeem your points for rewards"
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
|
|
@ -148,47 +174,57 @@ export default function StaffInternalMarketplace() {
|
|||
<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-amber-500/20 border border-amber-500/30">
|
||||
<ShoppingCart className="h-6 w-6 text-amber-400" />
|
||||
<Gift className="h-6 w-6 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-amber-100">
|
||||
Internal Marketplace
|
||||
Points Marketplace
|
||||
</h1>
|
||||
<p className="text-amber-200/70">
|
||||
Request services and resources from other teams
|
||||
Redeem your earned points for rewards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{/* Points Summary */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-amber-100">
|
||||
{services.length}
|
||||
</p>
|
||||
<p className="text-sm text-amber-200/70">
|
||||
Available Services
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-200/70">Your Balance</p>
|
||||
<p className="text-3xl font-bold text-amber-100">
|
||||
{points.balance.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Coins className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-amber-100">
|
||||
{
|
||||
services.filter((s) => s.availability === "Available")
|
||||
.length
|
||||
}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-200/70">Lifetime Earned</p>
|
||||
<p className="text-3xl font-bold text-amber-100">
|
||||
{points.lifetime_earned.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-amber-200/70">Ready to Book</p>
|
||||
</div>
|
||||
<Star className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-amber-100">
|
||||
{services.reduce((sum, s) => sum + s.requests, 0)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-200/70">My Orders</p>
|
||||
<p className="text-3xl font-bold text-amber-100">
|
||||
{orders.length}
|
||||
</p>
|
||||
<p className="text-sm text-amber-200/70">Total Requests</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -198,7 +234,7 @@ export default function StaffInternalMarketplace() {
|
|||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-5 w-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search services..."
|
||||
placeholder="Search rewards..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||
|
|
@ -225,66 +261,134 @@ export default function StaffInternalMarketplace() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{filtered.map((service) => (
|
||||
{/* Items Grid */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-12">
|
||||
{filtered.map((item) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
key={item.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-amber-500/50 transition-all"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-amber-100">
|
||||
{service.name}
|
||||
{item.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{service.provider}
|
||||
{item.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
className={`border ${getAvailabilityColor(service.availability)}`}
|
||||
>
|
||||
{service.availability}
|
||||
{item.stock_count !== null && item.stock_count < 10 && (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||
Only {item.stock_count} left
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
{service.turnaround}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{service.requests} requests
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-amber-400 font-semibold">
|
||||
<Coins className="h-4 w-4" />
|
||||
{item.points_cost.toLocaleString()} pts
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-amber-600 hover:bg-amber-700"
|
||||
disabled={service.availability === "Coming Soon"}
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
disabled={points.balance < item.points_cost}
|
||||
onClick={() => setOrderDialog(item)}
|
||||
>
|
||||
{service.availability === "Coming Soon"
|
||||
? "Coming Soon"
|
||||
: "Request Service"}
|
||||
Redeem
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No services found</p>
|
||||
<div className="text-center py-12 mb-12">
|
||||
<p className="text-slate-400">No rewards found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Orders */}
|
||||
{orders.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-amber-100 mb-6">Recent Orders</h2>
|
||||
<div className="space-y-4">
|
||||
{orders.slice(0, 5).map((order) => (
|
||||
<Card key={order.id} className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-amber-100 font-semibold">
|
||||
{order.item?.name || "Unknown Item"}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Qty: {order.quantity} • {new Date(order.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={getStatusColor(order.status)}>
|
||||
{order.status.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Dialog */}
|
||||
<Dialog open={!!orderDialog} onOpenChange={() => setOrderDialog(null)}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-100">
|
||||
Redeem {orderDialog?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-700/50 rounded">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-slate-300">Cost</span>
|
||||
<span className="text-amber-400 font-semibold">
|
||||
{orderDialog?.points_cost.toLocaleString()} pts
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-300">Your Balance After</span>
|
||||
<span className="text-slate-100">
|
||||
{(points.balance - (orderDialog?.points_cost || 0)).toLocaleString()} pts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-400 mb-2 block">Shipping Address</label>
|
||||
<Input
|
||||
placeholder="Enter your shipping address"
|
||||
value={shippingAddress}
|
||||
onChange={(e) => setShippingAddress(e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOrderDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={placeOrder}
|
||||
>
|
||||
Confirm Order
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -20,105 +20,133 @@ import {
|
|||
Users,
|
||||
Settings,
|
||||
Code,
|
||||
Loader2,
|
||||
ThumbsUp,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface KnowledgeArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
views: number;
|
||||
updated: string;
|
||||
icon: React.ReactNode;
|
||||
helpful_count: number;
|
||||
updated_at: string;
|
||||
author?: {
|
||||
full_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const articles: KnowledgeArticle[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Getting Started with AeThex Platform",
|
||||
category: "Onboarding",
|
||||
description: "Complete guide for new team members to get up to speed",
|
||||
tags: ["onboarding", "setup", "beginner"],
|
||||
views: 324,
|
||||
updated: "2 days ago",
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Troubleshooting Common Issues",
|
||||
category: "Support",
|
||||
description: "Step-by-step guides for resolving frequent problems",
|
||||
tags: ["troubleshooting", "support", "faq"],
|
||||
views: 156,
|
||||
updated: "1 week ago",
|
||||
icon: <AlertCircle className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "API Integration Guide",
|
||||
category: "Development",
|
||||
description: "How to integrate with AeThex APIs from your applications",
|
||||
tags: ["api", "development", "technical"],
|
||||
views: 89,
|
||||
updated: "3 weeks ago",
|
||||
icon: <Code className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Team Communication Standards",
|
||||
category: "Process",
|
||||
description: "Best practices for internal communications and channel usage",
|
||||
tags: ["communication", "process", "standards"],
|
||||
views: 201,
|
||||
updated: "4 days ago",
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Security & Access Control",
|
||||
category: "Security",
|
||||
description:
|
||||
"Security policies, password management, and access procedures",
|
||||
tags: ["security", "access", "compliance"],
|
||||
views: 112,
|
||||
updated: "1 day ago",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "Release Management Process",
|
||||
category: "Operations",
|
||||
description: "How to manage releases, deployments, and rollbacks",
|
||||
tags: ["devops", "release", "operations"],
|
||||
views: 67,
|
||||
updated: "2 weeks ago",
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
"All",
|
||||
"Onboarding",
|
||||
"Support",
|
||||
"Development",
|
||||
"Process",
|
||||
"Security",
|
||||
"Operations",
|
||||
];
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case "Onboarding":
|
||||
return <Zap className="h-5 w-5" />;
|
||||
case "Support":
|
||||
return <AlertCircle className="h-5 w-5" />;
|
||||
case "Development":
|
||||
return <Code className="h-5 w-5" />;
|
||||
case "Process":
|
||||
return <Users className="h-5 w-5" />;
|
||||
case "Security":
|
||||
return <Settings className="h-5 w-5" />;
|
||||
default:
|
||||
return <FileText className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function StaffKnowledgeBase() {
|
||||
const { session } = useAuth();
|
||||
const [articles, setArticles] = useState<KnowledgeArticle[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filtered = articles.filter((article) => {
|
||||
const matchesSearch =
|
||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory =
|
||||
selectedCategory === "All" || article.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchArticles();
|
||||
}
|
||||
}, [session?.access_token, selectedCategory, searchQuery]);
|
||||
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedCategory !== "all") params.append("category", selectedCategory);
|
||||
if (searchQuery) params.append("search", searchQuery);
|
||||
|
||||
const res = await fetch(`/api/staff/knowledge-base?${params}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setArticles(data.articles || []);
|
||||
setCategories(data.categories || []);
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load articles");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trackView = async (articleId: string) => {
|
||||
try {
|
||||
await fetch("/api/staff/knowledge-base", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ action: "view", id: articleId }),
|
||||
});
|
||||
} catch (err) {
|
||||
// Silent fail for analytics
|
||||
}
|
||||
};
|
||||
|
||||
const markHelpful = async (articleId: string) => {
|
||||
try {
|
||||
await fetch("/api/staff/knowledge-base", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ action: "helpful", id: articleId }),
|
||||
});
|
||||
aethexToast.success("Marked as helpful!");
|
||||
fetchArticles();
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to mark as helpful");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.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`;
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -163,12 +191,22 @@ export default function StaffKnowledgeBase() {
|
|||
|
||||
{/* Category Filter */}
|
||||
<div className="flex gap-2 mb-8 flex-wrap">
|
||||
<Button
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
className={
|
||||
selectedCategory === "all"
|
||||
? "bg-purple-600 hover:bg-purple-700"
|
||||
: "border-purple-500/30 text-purple-300 hover:bg-purple-500/10"
|
||||
}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={
|
||||
selectedCategory === category ? "default" : "outline"
|
||||
}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={
|
||||
|
|
@ -184,15 +222,16 @@ export default function StaffKnowledgeBase() {
|
|||
|
||||
{/* Articles Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{filtered.map((article) => (
|
||||
{articles.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-purple-500/50 transition-all cursor-pointer group"
|
||||
onClick={() => trackView(article.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="p-2 rounded bg-purple-500/20 text-purple-400 group-hover:bg-purple-500/30 transition-colors">
|
||||
{article.icon}
|
||||
{getCategoryIcon(article.category)}
|
||||
</div>
|
||||
<Badge className="bg-slate-700 text-slate-300 text-xs">
|
||||
{article.category}
|
||||
|
|
@ -202,11 +241,12 @@ export default function StaffKnowledgeBase() {
|
|||
{article.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{article.description}
|
||||
{article.content.substring(0, 150)}...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{article.tags && article.tags.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge
|
||||
|
|
@ -218,16 +258,30 @@ export default function StaffKnowledgeBase() {
|
|||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-700">
|
||||
<span className="text-xs text-slate-500">
|
||||
{article.views} views • {article.updated}
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{article.views}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
{article.helpful_count}
|
||||
</span>
|
||||
<span>{formatDate(article.updated_at)}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-purple-400 hover:text-purple-300 hover:bg-purple-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markHelpful(article.id);
|
||||
}}
|
||||
>
|
||||
Read
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
Helpful
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -236,7 +290,7 @@ export default function StaffKnowledgeBase() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{articles.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No articles found</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -19,109 +19,112 @@ import {
|
|||
FileText,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
instructor: string;
|
||||
description: string;
|
||||
category: string;
|
||||
duration: string;
|
||||
duration_weeks: number;
|
||||
lesson_count: number;
|
||||
is_required: boolean;
|
||||
progress: number;
|
||||
status: "In Progress" | "Completed" | "Available";
|
||||
lessons: number;
|
||||
icon: React.ReactNode;
|
||||
status: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
const courses: Course[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Advanced TypeScript Patterns",
|
||||
instructor: "Sarah Chen",
|
||||
category: "Development",
|
||||
duration: "4 weeks",
|
||||
progress: 65,
|
||||
status: "In Progress",
|
||||
lessons: 12,
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Leadership Fundamentals",
|
||||
instructor: "Marcus Johnson",
|
||||
category: "Leadership",
|
||||
duration: "6 weeks",
|
||||
progress: 0,
|
||||
status: "Available",
|
||||
lessons: 15,
|
||||
icon: <Award className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "AWS Solutions Architect",
|
||||
instructor: "David Lee",
|
||||
category: "Infrastructure",
|
||||
duration: "8 weeks",
|
||||
progress: 100,
|
||||
status: "Completed",
|
||||
lessons: 20,
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Product Management Essentials",
|
||||
instructor: "Elena Rodriguez",
|
||||
category: "Product",
|
||||
duration: "5 weeks",
|
||||
progress: 40,
|
||||
status: "In Progress",
|
||||
lessons: 14,
|
||||
icon: <Video className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Security Best Practices",
|
||||
instructor: "Alex Kim",
|
||||
category: "Security",
|
||||
duration: "3 weeks",
|
||||
progress: 0,
|
||||
status: "Available",
|
||||
lessons: 10,
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "Effective Communication",
|
||||
instructor: "Patricia Martinez",
|
||||
category: "Skills",
|
||||
duration: "2 weeks",
|
||||
progress: 100,
|
||||
status: "Completed",
|
||||
lessons: 8,
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
interface Stats {
|
||||
total: number;
|
||||
completed: number;
|
||||
in_progress: number;
|
||||
required: number;
|
||||
}
|
||||
|
||||
const getCourseIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case "Development":
|
||||
return <BookOpen className="h-5 w-5" />;
|
||||
case "Leadership":
|
||||
return <Award className="h-5 w-5" />;
|
||||
case "Infrastructure":
|
||||
return <Zap className="h-5 w-5" />;
|
||||
case "Product":
|
||||
return <Video className="h-5 w-5" />;
|
||||
default:
|
||||
return <FileText className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function StaffLearningPortal() {
|
||||
const { session } = useAuth();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, completed: 0, in_progress: 0, required: 0 });
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const categories = [
|
||||
"All",
|
||||
"Development",
|
||||
"Leadership",
|
||||
"Infrastructure",
|
||||
"Product",
|
||||
"Security",
|
||||
"Skills",
|
||||
];
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchCourses();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/courses", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setCourses(data.courses || []);
|
||||
setStats(data.stats || { total: 0, completed: 0, in_progress: 0, required: 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load courses");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startCourse = async (courseId: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/courses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ course_id: courseId, action: "start" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success("Course started!");
|
||||
fetchCourses();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to start course");
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ["All", ...new Set(courses.map((c) => c.category))];
|
||||
|
||||
const filtered =
|
||||
selectedCategory === "All"
|
||||
? courses
|
||||
: courses.filter((c) => c.category === selectedCategory);
|
||||
|
||||
const completed = courses.filter((c) => c.status === "Completed").length;
|
||||
const inProgress = courses.filter((c) => c.status === "In Progress").length;
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -159,7 +162,7 @@ export default function StaffLearningPortal() {
|
|||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-cyan-100">
|
||||
{courses.length}
|
||||
{stats.total}
|
||||
</p>
|
||||
<p className="text-sm text-cyan-200/70">Total Courses</p>
|
||||
</CardContent>
|
||||
|
|
@ -167,7 +170,7 @@ export default function StaffLearningPortal() {
|
|||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-cyan-100">
|
||||
{completed}
|
||||
{stats.completed}
|
||||
</p>
|
||||
<p className="text-sm text-cyan-200/70">Completed</p>
|
||||
</CardContent>
|
||||
|
|
@ -175,7 +178,7 @@ export default function StaffLearningPortal() {
|
|||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-cyan-100">
|
||||
{inProgress}
|
||||
{stats.in_progress}
|
||||
</p>
|
||||
<p className="text-sm text-cyan-200/70">In Progress</p>
|
||||
</CardContent>
|
||||
|
|
@ -183,7 +186,7 @@ export default function StaffLearningPortal() {
|
|||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-cyan-100">
|
||||
{Math.round((completed / courses.length) * 100)}%
|
||||
{stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0}%
|
||||
</p>
|
||||
<p className="text-sm text-cyan-200/70">Completion Rate</p>
|
||||
</CardContent>
|
||||
|
|
@ -223,25 +226,25 @@ export default function StaffLearningPortal() {
|
|||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="p-2 rounded bg-cyan-500/20 text-cyan-400">
|
||||
{course.icon}
|
||||
{getCourseIcon(course.category)}
|
||||
</div>
|
||||
<Badge
|
||||
className={
|
||||
course.status === "Completed"
|
||||
course.status === "completed"
|
||||
? "bg-green-500/20 text-green-300 border-green-500/30"
|
||||
: course.status === "In Progress"
|
||||
: course.status === "in_progress"
|
||||
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
}
|
||||
>
|
||||
{course.status}
|
||||
{course.status === "completed" ? "Completed" : course.status === "in_progress" ? "In Progress" : "Available"}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-cyan-100">
|
||||
{course.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
by {course.instructor}
|
||||
{course.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -259,20 +262,26 @@ export default function StaffLearningPortal() {
|
|||
<div className="flex gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
{course.duration}
|
||||
{course.duration_weeks} weeks
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<FileText className="h-4 w-4" />
|
||||
{course.lessons} lessons
|
||||
{course.lesson_count} lessons
|
||||
</div>
|
||||
{course.is_required && (
|
||||
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-700"
|
||||
onClick={() => course.status === "available" && startCourse(course.id)}
|
||||
>
|
||||
{course.status === "Completed"
|
||||
{course.status === "completed"
|
||||
? "Review Course"
|
||||
: course.status === "In Progress"
|
||||
: course.status === "in_progress"
|
||||
? "Continue"
|
||||
: "Enroll"}
|
||||
</Button>
|
||||
|
|
@ -280,6 +289,12 @@ export default function StaffLearningPortal() {
|
|||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No courses found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -18,86 +18,49 @@ import {
|
|||
Clock,
|
||||
Award,
|
||||
Users,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
period: string;
|
||||
status: "Pending" | "In Progress" | "Completed";
|
||||
reviewer?: string;
|
||||
dueDate: string;
|
||||
feedback?: number;
|
||||
selfAssessment?: boolean;
|
||||
status: string;
|
||||
overall_rating?: number;
|
||||
reviewer_comments?: string;
|
||||
employee_comments?: string;
|
||||
goals_met?: number;
|
||||
goals_total?: number;
|
||||
due_date: string;
|
||||
created_at: string;
|
||||
reviewer?: {
|
||||
full_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
score: number;
|
||||
lastQuarter: number;
|
||||
interface Stats {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
average_rating: number;
|
||||
}
|
||||
|
||||
const userReviews: Review[] = [
|
||||
{
|
||||
id: "1",
|
||||
period: "Q1 2025",
|
||||
status: "In Progress",
|
||||
dueDate: "March 31, 2025",
|
||||
selfAssessment: true,
|
||||
feedback: 3,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
period: "Q4 2024",
|
||||
status: "Completed",
|
||||
dueDate: "December 31, 2024",
|
||||
selfAssessment: true,
|
||||
feedback: 5,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
period: "Q3 2024",
|
||||
status: "Completed",
|
||||
dueDate: "September 30, 2024",
|
||||
selfAssessment: true,
|
||||
feedback: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const performanceMetrics: Metric[] = [
|
||||
{
|
||||
name: "Technical Skills",
|
||||
score: 8.5,
|
||||
lastQuarter: 8.2,
|
||||
},
|
||||
{
|
||||
name: "Communication",
|
||||
score: 8.8,
|
||||
lastQuarter: 8.5,
|
||||
},
|
||||
{
|
||||
name: "Collaboration",
|
||||
score: 9.0,
|
||||
lastQuarter: 8.7,
|
||||
},
|
||||
{
|
||||
name: "Leadership",
|
||||
score: 8.2,
|
||||
lastQuarter: 7.9,
|
||||
},
|
||||
{
|
||||
name: "Problem Solving",
|
||||
score: 8.7,
|
||||
lastQuarter: 8.4,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "Completed":
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "In Progress":
|
||||
case "in_progress":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "Pending":
|
||||
case "pending":
|
||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
|
|
@ -105,14 +68,71 @@ const getStatusColor = (status: string) => {
|
|||
};
|
||||
|
||||
export default function StaffPerformanceReviews() {
|
||||
const { session } = useAuth();
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, pending: 0, completed: 0, average_rating: 0 });
|
||||
const [selectedReview, setSelectedReview] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentDialog, setCommentDialog] = useState<Review | null>(null);
|
||||
const [employeeComments, setEmployeeComments] = useState("");
|
||||
|
||||
const avgScore =
|
||||
Math.round(
|
||||
(performanceMetrics.reduce((sum, m) => sum + m.score, 0) /
|
||||
performanceMetrics.length) *
|
||||
10,
|
||||
) / 10;
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchReviews();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchReviews = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/reviews", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReviews(data.reviews || []);
|
||||
setStats(data.stats || { total: 0, pending: 0, completed: 0, average_rating: 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load reviews");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitComments = async () => {
|
||||
if (!commentDialog) return;
|
||||
try {
|
||||
const res = await fetch("/api/staff/reviews", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
review_id: commentDialog.id,
|
||||
employee_comments: employeeComments,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success("Comments submitted");
|
||||
setCommentDialog(null);
|
||||
setEmployeeComments("");
|
||||
fetchReviews();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to submit comments");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -151,20 +171,20 @@ export default function StaffPerformanceReviews() {
|
|||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<p className="text-sm text-purple-200/70 mb-2">
|
||||
Overall Rating
|
||||
Average Rating
|
||||
</p>
|
||||
<p className="text-5xl font-bold text-purple-100 mb-4">
|
||||
{avgScore}
|
||||
{stats.average_rating.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-slate-400">
|
||||
Based on 5 performance dimensions
|
||||
Based on {stats.completed} completed reviews
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Award className="h-16 w-16 text-purple-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-purple-200/70">
|
||||
Exceeds Expectations
|
||||
{stats.average_rating >= 4 ? "Exceeds Expectations" : stats.average_rating >= 3 ? "Meets Expectations" : "Needs Improvement"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,39 +192,26 @@ export default function StaffPerformanceReviews() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-purple-100 mb-6">
|
||||
Performance Dimensions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{performanceMetrics.map((metric) => (
|
||||
<Card
|
||||
key={metric.name}
|
||||
className="bg-slate-800/50 border-slate-700/50"
|
||||
>
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-purple-100">
|
||||
{metric.name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Last quarter: {metric.lastQuarter}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-300">
|
||||
{metric.score}
|
||||
</p>
|
||||
</div>
|
||||
<Progress
|
||||
value={(metric.score / 10) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-2xl font-bold text-purple-100">{stats.total}</p>
|
||||
<p className="text-sm text-purple-200/70">Total Reviews</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-purple-100">{stats.pending}</p>
|
||||
<p className="text-sm text-purple-200/70">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-2xl font-bold text-purple-100">{stats.completed}</p>
|
||||
<p className="text-sm text-purple-200/70">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review History */}
|
||||
|
|
@ -213,7 +220,7 @@ export default function StaffPerformanceReviews() {
|
|||
Review History
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{userReviews.map((review) => (
|
||||
{reviews.map((review) => (
|
||||
<Card
|
||||
key={review.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-purple-500/50 transition-all cursor-pointer"
|
||||
|
|
@ -230,65 +237,71 @@ export default function StaffPerformanceReviews() {
|
|||
{review.period} Review
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Due: {review.dueDate}
|
||||
Due: {new Date(review.due_date).toLocaleDateString()}
|
||||
{review.reviewer && ` • Reviewer: ${review.reviewer.full_name}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
className={`border ${getStatusColor(review.status)}`}
|
||||
>
|
||||
{review.status}
|
||||
{review.status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{selectedReview === review.id && (
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{review.selfAssessment && (
|
||||
{review.overall_rating && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||
<Award className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">Rating</p>
|
||||
<p className="text-sm text-purple-300">
|
||||
{review.overall_rating}/5
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{review.goals_total && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||
<CheckCircle className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">Goals Met</p>
|
||||
<p className="text-sm text-purple-300">
|
||||
{review.goals_met}/{review.goals_total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||
<MessageSquare className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">
|
||||
Self Assessment
|
||||
</p>
|
||||
<p className="text-sm text-slate-300">Your Comments</p>
|
||||
<p className="text-sm text-purple-300">
|
||||
Completed
|
||||
{review.employee_comments ? "Submitted" : "Not submitted"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{review.reviewer_comments && (
|
||||
<div className="p-4 bg-slate-700/30 rounded">
|
||||
<p className="text-sm text-slate-400 mb-2">Reviewer Comments:</p>
|
||||
<p className="text-slate-200">{review.reviewer_comments}</p>
|
||||
</div>
|
||||
)}
|
||||
{review.feedback && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||
<Users className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">
|
||||
360 Feedback
|
||||
</p>
|
||||
<p className="text-sm text-purple-300">
|
||||
{review.feedback} responses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{review.status === "Completed" && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">
|
||||
Manager Review
|
||||
</p>
|
||||
<p className="text-sm text-green-300">
|
||||
Completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCommentDialog(review);
|
||||
setEmployeeComments(review.employee_comments || "");
|
||||
}}
|
||||
>
|
||||
View Full Review
|
||||
{review.employee_comments ? "Edit Comments" : "Add Comments"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -296,39 +309,44 @@ export default function StaffPerformanceReviews() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Items */}
|
||||
<Card className="bg-slate-800/50 border-purple-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-purple-100">Next Steps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-purple-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-purple-100">
|
||||
Complete Q1 Self Assessment
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Due by March 31, 2025
|
||||
</p>
|
||||
{reviews.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No reviews found</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<MessageSquare className="h-5 w-5 text-purple-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-purple-100">
|
||||
Schedule 1:1 with Manager
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Discuss Q1 progress and goals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Dialog */}
|
||||
<Dialog open={!!commentDialog} onOpenChange={() => setCommentDialog(null)}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-purple-100">
|
||||
{commentDialog?.period} Review Comments
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="Add your comments about this review..."
|
||||
value={employeeComments}
|
||||
onChange={(e) => setEmployeeComments(e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100 min-h-[150px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setCommentDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={submitComments}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -12,110 +12,189 @@ import {
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
BarChart,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
Users,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Plus,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface OKR {
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
owner: string;
|
||||
progress: number;
|
||||
status: "On Track" | "At Risk" | "Completed";
|
||||
quarter: string;
|
||||
team: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
const okrs: OKR[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Improve Platform Performance by 40%",
|
||||
description: "Reduce page load time and increase throughput",
|
||||
owner: "Engineering",
|
||||
progress: 75,
|
||||
status: "On Track",
|
||||
quarter: "Q1 2025",
|
||||
team: "DevOps",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Expand Creator Network to 5K Members",
|
||||
description: "Grow creator base through partnerships and incentives",
|
||||
owner: "Community",
|
||||
progress: 62,
|
||||
status: "On Track",
|
||||
quarter: "Q1 2025",
|
||||
team: "Growth",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Launch New Learning Curriculum",
|
||||
description: "Complete redesign of Foundation learning paths",
|
||||
owner: "Foundation",
|
||||
progress: 45,
|
||||
status: "At Risk",
|
||||
quarter: "Q1 2025",
|
||||
team: "Education",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Achieve 99.99% Uptime",
|
||||
description: "Maintain service reliability and reduce downtime",
|
||||
owner: "Infrastructure",
|
||||
progress: 88,
|
||||
status: "On Track",
|
||||
quarter: "Q1 2025",
|
||||
team: "Ops",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Launch Roblox Game Studio Partnership",
|
||||
description: "Formalize GameForge partnerships with major studios",
|
||||
owner: "GameForge",
|
||||
progress: 30,
|
||||
status: "On Track",
|
||||
quarter: "Q1 2025",
|
||||
team: "Partnerships",
|
||||
},
|
||||
];
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
lead?: {
|
||||
full_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
tasks: Task[];
|
||||
task_stats: {
|
||||
total: number;
|
||||
done: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "On Track":
|
||||
case "active":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "At Risk":
|
||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
case "Completed":
|
||||
case "completed":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "on_hold":
|
||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return "bg-green-500/20 text-green-300";
|
||||
case "in_progress":
|
||||
return "bg-blue-500/20 text-blue-300";
|
||||
case "todo":
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
export default function StaffProjectTracking() {
|
||||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||
const { session } = useAuth();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, active: 0, completed: 0 });
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskDialog, setTaskDialog] = useState<string | null>(null);
|
||||
const [newTask, setNewTask] = useState({ title: "", description: "", priority: "medium", due_date: "" });
|
||||
|
||||
const teams = Array.from(new Set(okrs.map((okr) => okr.team)));
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchProjects();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const filtered = selectedTeam
|
||||
? okrs.filter((okr) => okr.team === selectedTeam)
|
||||
: okrs;
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/projects", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProjects(data.projects || []);
|
||||
setStats(data.stats || { total: 0, active: 0, completed: 0 });
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load projects");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const avgProgress =
|
||||
Math.round(
|
||||
filtered.reduce((sum, okr) => sum + okr.progress, 0) / filtered.length,
|
||||
) || 0;
|
||||
const updateTaskStatus = async (taskId: string, status: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/projects", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ action: "update_task", task_id: taskId, status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success("Task updated");
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to update task");
|
||||
}
|
||||
};
|
||||
|
||||
const createTask = async () => {
|
||||
if (!taskDialog || !newTask.title) return;
|
||||
try {
|
||||
const res = await fetch("/api/staff/projects", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "create_task",
|
||||
project_id: taskDialog,
|
||||
...newTask,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success("Task created");
|
||||
setTaskDialog(null);
|
||||
setNewTask({ title: "", description: "", priority: "medium", due_date: "" });
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to create task");
|
||||
}
|
||||
};
|
||||
|
||||
const avgProgress = projects.length > 0
|
||||
? Math.round(
|
||||
projects.reduce((sum, p) => sum + (p.task_stats.total > 0 ? (p.task_stats.done / p.task_stats.total) * 100 : 0), 0) / projects.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-indigo-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO
|
||||
title="Project Tracking"
|
||||
description="AeThex OKRs, initiatives, and roadmap"
|
||||
description="AeThex projects, tasks, and roadmap"
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
|
|
@ -137,7 +216,7 @@ export default function StaffProjectTracking() {
|
|||
Project Tracking
|
||||
</h1>
|
||||
<p className="text-indigo-200/70">
|
||||
OKRs, initiatives, and company-wide roadmap
|
||||
Your projects, tasks, and progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -148,9 +227,9 @@ export default function StaffProjectTracking() {
|
|||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-indigo-200/70">Active OKRs</p>
|
||||
<p className="text-sm text-indigo-200/70">My Projects</p>
|
||||
<p className="text-3xl font-bold text-indigo-100">
|
||||
{filtered.length}
|
||||
{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
<Target className="h-8 w-8 text-indigo-400" />
|
||||
|
|
@ -174,9 +253,9 @@ export default function StaffProjectTracking() {
|
|||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-indigo-200/70">On Track</p>
|
||||
<p className="text-sm text-indigo-200/70">Active</p>
|
||||
<p className="text-3xl font-bold text-indigo-100">
|
||||
{filtered.filter((o) => o.status === "On Track").length}
|
||||
{stats.active}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-indigo-400" />
|
||||
|
|
@ -185,93 +264,180 @@ export default function StaffProjectTracking() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Team Filter */}
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-indigo-200/70 mb-3">Filter by Team:</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={selectedTeam === null ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTeam(null)}
|
||||
className={
|
||||
selectedTeam === null
|
||||
? "bg-indigo-600 hover:bg-indigo-700"
|
||||
: "border-indigo-500/30 text-indigo-300 hover:bg-indigo-500/10"
|
||||
}
|
||||
>
|
||||
All Teams
|
||||
</Button>
|
||||
{teams.map((team) => (
|
||||
<Button
|
||||
key={team}
|
||||
variant={selectedTeam === team ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTeam(team)}
|
||||
className={
|
||||
selectedTeam === team
|
||||
? "bg-indigo-600 hover:bg-indigo-700"
|
||||
: "border-indigo-500/30 text-indigo-300 hover:bg-indigo-500/10"
|
||||
}
|
||||
>
|
||||
{team}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OKRs */}
|
||||
{/* Projects */}
|
||||
<div className="space-y-6">
|
||||
{filtered.map((okr) => (
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={okr.id}
|
||||
key={project.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-indigo-500/50 transition-all"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedProject(selectedProject === project.id ? null : project.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-indigo-100">
|
||||
{okr.title}
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{okr.description}
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={`border ${getStatusColor(okr.status)}`}>
|
||||
{okr.status}
|
||||
<Badge className={`border ${getStatusColor(project.status)}`}>
|
||||
{project.status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Progress</span>
|
||||
<span className="text-slate-400">Tasks Progress</span>
|
||||
<span className="text-indigo-300 font-semibold">
|
||||
{okr.progress}%
|
||||
{project.task_stats.done}/{project.task_stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={okr.progress} className="h-2" />
|
||||
<Progress
|
||||
value={project.task_stats.total > 0 ? (project.task_stats.done / project.task_stats.total) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex gap-4 flex-wrap text-sm">
|
||||
{project.lead && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Owner</p>
|
||||
<p className="text-sm text-indigo-300">{okr.owner}</p>
|
||||
<p className="text-xs text-slate-500">Lead</p>
|
||||
<p className="text-indigo-300">{project.lead.full_name}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Quarter</p>
|
||||
<p className="text-sm text-indigo-300">{okr.quarter}</p>
|
||||
<p className="text-xs text-slate-500">Start Date</p>
|
||||
<p className="text-indigo-300">{new Date(project.start_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{project.end_date && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Team</p>
|
||||
<p className="text-sm text-indigo-300">{okr.team}</p>
|
||||
<p className="text-xs text-slate-500">End Date</p>
|
||||
<p className="text-indigo-300">{new Date(project.end_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProject === project.id && (
|
||||
<div className="pt-4 border-t border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-lg font-semibold text-indigo-100">Tasks</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
onClick={() => setTaskDialog(project.id)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{project.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-700/30 rounded"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-slate-200">{task.title}</p>
|
||||
{task.due_date && (
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Due: {new Date(task.due_date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={task.status}
|
||||
onValueChange={(value) => updateTaskStatus(task.id, value)}
|
||||
>
|
||||
<SelectTrigger className={`w-32 ${getTaskStatusColor(task.status)}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{project.tasks.length === 0 && (
|
||||
<p className="text-slate-500 text-sm text-center py-4">No tasks yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No projects found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Task Dialog */}
|
||||
<Dialog open={!!taskDialog} onOpenChange={() => setTaskDialog(null)}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-indigo-100">Add New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Description (optional)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
value={newTask.priority}
|
||||
onValueChange={(value) => setNewTask({ ...newTask, priority: value })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-slate-100">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
value={newTask.due_date}
|
||||
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setTaskDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
onClick={createTask}
|
||||
>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -11,94 +12,88 @@ import {
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Heart,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
Shield,
|
||||
Zap,
|
||||
Award,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface HandbookSection {
|
||||
id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
content: string;
|
||||
subsections: string[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
const sections: HandbookSection[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Benefits & Compensation",
|
||||
icon: <Heart className="h-6 w-6" />,
|
||||
content: "Comprehensive benefits package including health, dental, vision",
|
||||
subsections: [
|
||||
"Health Insurance",
|
||||
"Retirement Plans",
|
||||
"Stock Options",
|
||||
"Flexible PTO",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Company Policies",
|
||||
icon: <Shield className="h-6 w-6" />,
|
||||
content: "Core policies governing workplace conduct and expectations",
|
||||
subsections: [
|
||||
"Code of Conduct",
|
||||
"Harassment Policy",
|
||||
"Confidentiality",
|
||||
"Data Security",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Time Off & Leave",
|
||||
icon: <Calendar className="h-6 w-6" />,
|
||||
content: "Vacation, sick leave, parental leave, and special circumstances",
|
||||
subsections: [
|
||||
"Paid Time Off",
|
||||
"Sick Leave",
|
||||
"Parental Leave",
|
||||
"Sabbatical",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Remote Work & Flexibility",
|
||||
icon: <MapPin className="h-6 w-6" />,
|
||||
content: "Work from home policies, office hours, and location flexibility",
|
||||
subsections: ["WFH Policy", "Core Hours", "Office Access", "Equipment"],
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Professional Development",
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
content: "Learning opportunities, training budgets, and career growth",
|
||||
subsections: [
|
||||
"Training Budget",
|
||||
"Conference Attendance",
|
||||
"Internal Training",
|
||||
"Mentorship",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "Recognition & Awards",
|
||||
icon: <Award className="h-6 w-6" />,
|
||||
content: "Employee recognition programs and performance incentives",
|
||||
subsections: [
|
||||
"Spot Bonuses",
|
||||
"Team Awards",
|
||||
"Anniversary Recognition",
|
||||
"Excellence Awards",
|
||||
],
|
||||
},
|
||||
];
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case "Benefits":
|
||||
return <Heart className="h-6 w-6" />;
|
||||
case "Policies":
|
||||
return <Shield className="h-6 w-6" />;
|
||||
case "Time Off":
|
||||
return <Calendar className="h-6 w-6" />;
|
||||
case "Remote Work":
|
||||
return <MapPin className="h-6 w-6" />;
|
||||
case "Development":
|
||||
return <Zap className="h-6 w-6" />;
|
||||
case "Recognition":
|
||||
return <Award className="h-6 w-6" />;
|
||||
default:
|
||||
return <Users className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function StaffTeamHandbook() {
|
||||
const { session } = useAuth();
|
||||
const [sections, setSections] = useState<HandbookSection[]>([]);
|
||||
const [grouped, setGrouped] = useState<Record<string, HandbookSection[]>>({});
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchHandbook();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchHandbook = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/staff/handbook", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSections(data.sections || []);
|
||||
setGrouped(data.grouped || {});
|
||||
setCategories(data.categories || []);
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load handbook");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO
|
||||
|
|
@ -158,50 +153,67 @@ export default function StaffTeamHandbook() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Handbook Sections */}
|
||||
{/* Handbook Sections by Category */}
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => (
|
||||
{categories.map((category) => (
|
||||
<Card
|
||||
key={section.id}
|
||||
key={category}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-blue-500/50 transition-all"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20 text-blue-400">
|
||||
{section.icon}
|
||||
{getCategoryIcon(category)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-blue-100">
|
||||
{section.title}
|
||||
{category}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{section.content}
|
||||
{grouped[category]?.length || 0} sections
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{expandedCategory === category ? (
|
||||
<ChevronUp className="h-5 w-5 text-blue-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{section.subsections.map((subsection) => (
|
||||
<Badge
|
||||
key={subsection}
|
||||
variant="secondary"
|
||||
className="bg-slate-700/50 text-slate-300"
|
||||
{expandedCategory === category && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4 pl-14">
|
||||
{grouped[category]?.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="p-4 bg-slate-700/30 rounded-lg"
|
||||
>
|
||||
{subsection}
|
||||
</Badge>
|
||||
<h4 className="font-semibold text-blue-100 mb-2">
|
||||
{section.title}
|
||||
</h4>
|
||||
<p className="text-slate-300 text-sm whitespace-pre-line">
|
||||
{section.content}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
||||
View Details
|
||||
</Button>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">No handbook sections found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Resources */}
|
||||
<div className="mt-12 p-6 rounded-lg bg-slate-800/50 border border-blue-500/30">
|
||||
<h2 className="text-xl font-bold text-blue-100 mb-4">
|
||||
|
|
|
|||
Loading…
Reference in a new issue