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 Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -13,127 +13,153 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Search,
|
Search,
|
||||||
Users,
|
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
Gift,
|
||||||
CheckCircle,
|
Star,
|
||||||
|
Package,
|
||||||
|
Loader2,
|
||||||
|
Coins,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider: string;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
availability: "Available" | "Booked" | "Coming Soon";
|
category: string;
|
||||||
turnaround: string;
|
points_cost: number;
|
||||||
requests: number;
|
image_url?: string;
|
||||||
|
stock_count?: number;
|
||||||
|
is_available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const services: Service[] = [
|
interface Order {
|
||||||
{
|
id: string;
|
||||||
id: "1",
|
quantity: number;
|
||||||
name: "Design Consultation",
|
status: string;
|
||||||
provider: "Design Team",
|
created_at: string;
|
||||||
category: "Design",
|
item?: {
|
||||||
description: "1-on-1 design review and UX guidance for your project",
|
name: string;
|
||||||
availability: "Available",
|
image_url?: string;
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getAvailabilityColor = (availability: string) => {
|
interface Points {
|
||||||
switch (availability) {
|
balance: number;
|
||||||
case "Available":
|
lifetime_earned: number;
|
||||||
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";
|
|
||||||
default:
|
|
||||||
return "bg-slate-500/20 text-slate-300";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StaffInternalMarketplace() {
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [orderDialog, setOrderDialog] = useState<MarketplaceItem | null>(null);
|
||||||
|
const [shippingAddress, setShippingAddress] = useState("");
|
||||||
|
|
||||||
const categories = [
|
useEffect(() => {
|
||||||
"All",
|
if (session?.access_token) {
|
||||||
"Design",
|
fetchMarketplace();
|
||||||
"Development",
|
}
|
||||||
"Security",
|
}, [session?.access_token]);
|
||||||
"Infrastructure",
|
|
||||||
"Product",
|
|
||||||
];
|
|
||||||
|
|
||||||
const filtered = services.filter((service) => {
|
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 =
|
const matchesSearch =
|
||||||
service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
service.provider.toLowerCase().includes(searchQuery.toLowerCase());
|
item.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesCategory =
|
const matchesCategory =
|
||||||
selectedCategory === "All" || service.category === selectedCategory;
|
selectedCategory === "All" || item.category === selectedCategory;
|
||||||
return matchesSearch && matchesCategory;
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<SEO
|
<SEO
|
||||||
title="Internal Marketplace"
|
title="Points Marketplace"
|
||||||
description="Request services from other teams"
|
description="Redeem your points for rewards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
<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="container mx-auto max-w-6xl px-4 py-16">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="p-3 rounded-lg bg-amber-500/20 border border-amber-500/30">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-amber-100">
|
<h1 className="text-4xl font-bold text-amber-100">
|
||||||
Internal Marketplace
|
Points Marketplace
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-amber-200/70">
|
<p className="text-amber-200/70">
|
||||||
Request services and resources from other teams
|
Redeem your earned points for rewards
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Points Summary */}
|
||||||
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-amber-100">
|
<div className="flex items-center justify-between">
|
||||||
{services.length}
|
<div>
|
||||||
</p>
|
<p className="text-sm text-amber-200/70">Your Balance</p>
|
||||||
<p className="text-sm text-amber-200/70">
|
<p className="text-3xl font-bold text-amber-100">
|
||||||
Available Services
|
{points.balance.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Coins className="h-8 w-8 text-amber-400" />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-amber-100">
|
<div className="flex items-center justify-between">
|
||||||
{
|
<div>
|
||||||
services.filter((s) => s.availability === "Available")
|
<p className="text-sm text-amber-200/70">Lifetime Earned</p>
|
||||||
.length
|
<p className="text-3xl font-bold text-amber-100">
|
||||||
}
|
{points.lifetime_earned.toLocaleString()}
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-amber-950/30 border-amber-500/30">
|
<Card className="bg-amber-950/30 border-amber-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-amber-100">
|
<div className="flex items-center justify-between">
|
||||||
{services.reduce((sum, s) => sum + s.requests, 0)}
|
<div>
|
||||||
</p>
|
<p className="text-sm text-amber-200/70">My Orders</p>
|
||||||
<p className="text-sm text-amber-200/70">Total Requests</p>
|
<p className="text-3xl font-bold text-amber-100">
|
||||||
|
{orders.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-amber-400" />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -198,7 +234,7 @@ export default function StaffInternalMarketplace() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-5 w-5 text-slate-400" />
|
<Search className="absolute left-3 top-3 h-5 w-5 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search services..."
|
placeholder="Search rewards..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||||
|
|
@ -225,66 +261,134 @@ export default function StaffInternalMarketplace() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services Grid */}
|
{/* Items Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-3 gap-6 mb-12">
|
||||||
{filtered.map((service) => (
|
{filtered.map((item) => (
|
||||||
<Card
|
<Card
|
||||||
key={service.id}
|
key={item.id}
|
||||||
className="bg-slate-800/50 border-slate-700/50 hover:border-amber-500/50 transition-all"
|
className="bg-slate-800/50 border-slate-700/50 hover:border-amber-500/50 transition-all"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-amber-100">
|
<CardTitle className="text-amber-100">
|
||||||
{service.name}
|
{item.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{service.provider}
|
{item.category}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
{item.stock_count !== null && item.stock_count < 10 && (
|
||||||
className={`border ${getAvailabilityColor(service.availability)}`}
|
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||||
>
|
Only {item.stock_count} left
|
||||||
{service.availability}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-slate-300">
|
<p className="text-sm text-slate-300">{item.description}</p>
|
||||||
{service.description}
|
<div className="flex items-center justify-between">
|
||||||
</p>
|
<div className="flex items-center gap-1 text-amber-400 font-semibold">
|
||||||
<div className="flex gap-4 text-sm">
|
<Coins className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
{item.points_cost.toLocaleString()} pts
|
||||||
<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>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
disabled={points.balance < item.points_cost}
|
||||||
|
onClick={() => setOrderDialog(item)}
|
||||||
|
>
|
||||||
|
Redeem
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full bg-amber-600 hover:bg-amber-700"
|
|
||||||
disabled={service.availability === "Coming Soon"}
|
|
||||||
>
|
|
||||||
{service.availability === "Coming Soon"
|
|
||||||
? "Coming Soon"
|
|
||||||
: "Request Service"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12 mb-12">
|
||||||
<p className="text-slate-400">No services found</p>
|
<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>
|
||||||
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -20,105 +20,133 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
Code,
|
Code,
|
||||||
|
Loader2,
|
||||||
|
ThumbsUp,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||||
|
|
||||||
interface KnowledgeArticle {
|
interface KnowledgeArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
content: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
views: number;
|
views: number;
|
||||||
updated: string;
|
helpful_count: number;
|
||||||
icon: React.ReactNode;
|
updated_at: string;
|
||||||
|
author?: {
|
||||||
|
full_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const articles: KnowledgeArticle[] = [
|
const getCategoryIcon = (category: string) => {
|
||||||
{
|
switch (category) {
|
||||||
id: "1",
|
case "Onboarding":
|
||||||
title: "Getting Started with AeThex Platform",
|
return <Zap className="h-5 w-5" />;
|
||||||
category: "Onboarding",
|
case "Support":
|
||||||
description: "Complete guide for new team members to get up to speed",
|
return <AlertCircle className="h-5 w-5" />;
|
||||||
tags: ["onboarding", "setup", "beginner"],
|
case "Development":
|
||||||
views: 324,
|
return <Code className="h-5 w-5" />;
|
||||||
updated: "2 days ago",
|
case "Process":
|
||||||
icon: <Zap className="h-5 w-5" />,
|
return <Users className="h-5 w-5" />;
|
||||||
},
|
case "Security":
|
||||||
{
|
return <Settings className="h-5 w-5" />;
|
||||||
id: "2",
|
default:
|
||||||
title: "Troubleshooting Common Issues",
|
return <FileText className="h-5 w-5" />;
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function StaffKnowledgeBase() {
|
export default function StaffKnowledgeBase() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [articles, setArticles] = useState<KnowledgeArticle[]>([]);
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const filtered = articles.filter((article) => {
|
useEffect(() => {
|
||||||
const matchesSearch =
|
if (session?.access_token) {
|
||||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
fetchArticles();
|
||||||
article.description.toLowerCase().includes(searchQuery.toLowerCase());
|
}
|
||||||
const matchesCategory =
|
}, [session?.access_token, selectedCategory, searchQuery]);
|
||||||
selectedCategory === "All" || article.category === selectedCategory;
|
|
||||||
return matchesSearch && matchesCategory;
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -163,12 +191,22 @@ export default function StaffKnowledgeBase() {
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category Filter */}
|
||||||
<div className="flex gap-2 mb-8 flex-wrap">
|
<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) => (
|
{categories.map((category) => (
|
||||||
<Button
|
<Button
|
||||||
key={category}
|
key={category}
|
||||||
variant={
|
variant={selectedCategory === category ? "default" : "outline"}
|
||||||
selectedCategory === category ? "default" : "outline"
|
|
||||||
}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(category)}
|
||||||
className={
|
className={
|
||||||
|
|
@ -184,15 +222,16 @@ export default function StaffKnowledgeBase() {
|
||||||
|
|
||||||
{/* Articles Grid */}
|
{/* Articles Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
{filtered.map((article) => (
|
{articles.map((article) => (
|
||||||
<Card
|
<Card
|
||||||
key={article.id}
|
key={article.id}
|
||||||
className="bg-slate-800/50 border-slate-700/50 hover:border-purple-500/50 transition-all cursor-pointer group"
|
className="bg-slate-800/50 border-slate-700/50 hover:border-purple-500/50 transition-all cursor-pointer group"
|
||||||
|
onClick={() => trackView(article.id)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<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">
|
<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>
|
</div>
|
||||||
<Badge className="bg-slate-700 text-slate-300 text-xs">
|
<Badge className="bg-slate-700 text-slate-300 text-xs">
|
||||||
{article.category}
|
{article.category}
|
||||||
|
|
@ -202,32 +241,47 @@ export default function StaffKnowledgeBase() {
|
||||||
{article.title}
|
{article.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{article.description}
|
{article.content.substring(0, 150)}...
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2 flex-wrap">
|
{article.tags && article.tags.length > 0 && (
|
||||||
{article.tags.map((tag) => (
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Badge
|
{article.tags.map((tag) => (
|
||||||
key={tag}
|
<Badge
|
||||||
variant="secondary"
|
key={tag}
|
||||||
className="bg-slate-700/50 text-slate-300 text-xs"
|
variant="secondary"
|
||||||
>
|
className="bg-slate-700/50 text-slate-300 text-xs"
|
||||||
{tag}
|
>
|
||||||
</Badge>
|
{tag}
|
||||||
))}
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-slate-700">
|
<div className="flex items-center justify-between pt-4 border-t border-slate-700">
|
||||||
<span className="text-xs text-slate-500">
|
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||||
{article.views} views • {article.updated}
|
<span className="flex items-center gap-1">
|
||||||
</span>
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-purple-400 hover:text-purple-300 hover:bg-purple-500/20"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -236,7 +290,7 @@ export default function StaffKnowledgeBase() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{articles.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-slate-400">No articles found</p>
|
<p className="text-slate-400">No articles found</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -19,109 +19,112 @@ import {
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||||
|
|
||||||
interface Course {
|
interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
instructor: string;
|
description: string;
|
||||||
category: string;
|
category: string;
|
||||||
duration: string;
|
duration_weeks: number;
|
||||||
|
lesson_count: number;
|
||||||
|
is_required: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
status: "In Progress" | "Completed" | "Available";
|
status: string;
|
||||||
lessons: number;
|
started_at?: string;
|
||||||
icon: React.ReactNode;
|
completed_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const courses: Course[] = [
|
interface Stats {
|
||||||
{
|
total: number;
|
||||||
id: "1",
|
completed: number;
|
||||||
title: "Advanced TypeScript Patterns",
|
in_progress: number;
|
||||||
instructor: "Sarah Chen",
|
required: number;
|
||||||
category: "Development",
|
}
|
||||||
duration: "4 weeks",
|
|
||||||
progress: 65,
|
const getCourseIcon = (category: string) => {
|
||||||
status: "In Progress",
|
switch (category) {
|
||||||
lessons: 12,
|
case "Development":
|
||||||
icon: <BookOpen className="h-5 w-5" />,
|
return <BookOpen className="h-5 w-5" />;
|
||||||
},
|
case "Leadership":
|
||||||
{
|
return <Award className="h-5 w-5" />;
|
||||||
id: "2",
|
case "Infrastructure":
|
||||||
title: "Leadership Fundamentals",
|
return <Zap className="h-5 w-5" />;
|
||||||
instructor: "Marcus Johnson",
|
case "Product":
|
||||||
category: "Leadership",
|
return <Video className="h-5 w-5" />;
|
||||||
duration: "6 weeks",
|
default:
|
||||||
progress: 0,
|
return <FileText className="h-5 w-5" />;
|
||||||
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" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function StaffLearningPortal() {
|
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 [selectedCategory, setSelectedCategory] = useState("All");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const categories = [
|
useEffect(() => {
|
||||||
"All",
|
if (session?.access_token) {
|
||||||
"Development",
|
fetchCourses();
|
||||||
"Leadership",
|
}
|
||||||
"Infrastructure",
|
}, [session?.access_token]);
|
||||||
"Product",
|
|
||||||
"Security",
|
const fetchCourses = async () => {
|
||||||
"Skills",
|
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 =
|
const filtered =
|
||||||
selectedCategory === "All"
|
selectedCategory === "All"
|
||||||
? courses
|
? courses
|
||||||
: courses.filter((c) => c.category === selectedCategory);
|
: courses.filter((c) => c.category === selectedCategory);
|
||||||
|
|
||||||
const completed = courses.filter((c) => c.status === "Completed").length;
|
if (loading) {
|
||||||
const inProgress = courses.filter((c) => c.status === "In Progress").length;
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -159,7 +162,7 @@ export default function StaffLearningPortal() {
|
||||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-cyan-100">
|
<p className="text-2xl font-bold text-cyan-100">
|
||||||
{courses.length}
|
{stats.total}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-cyan-200/70">Total Courses</p>
|
<p className="text-sm text-cyan-200/70">Total Courses</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -167,7 +170,7 @@ export default function StaffLearningPortal() {
|
||||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-cyan-100">
|
<p className="text-2xl font-bold text-cyan-100">
|
||||||
{completed}
|
{stats.completed}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-cyan-200/70">Completed</p>
|
<p className="text-sm text-cyan-200/70">Completed</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -175,7 +178,7 @@ export default function StaffLearningPortal() {
|
||||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-cyan-100">
|
<p className="text-2xl font-bold text-cyan-100">
|
||||||
{inProgress}
|
{stats.in_progress}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-cyan-200/70">In Progress</p>
|
<p className="text-sm text-cyan-200/70">In Progress</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -183,7 +186,7 @@ export default function StaffLearningPortal() {
|
||||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-2xl font-bold text-cyan-100">
|
<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>
|
||||||
<p className="text-sm text-cyan-200/70">Completion Rate</p>
|
<p className="text-sm text-cyan-200/70">Completion Rate</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -223,25 +226,25 @@ export default function StaffLearningPortal() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="p-2 rounded bg-cyan-500/20 text-cyan-400">
|
<div className="p-2 rounded bg-cyan-500/20 text-cyan-400">
|
||||||
{course.icon}
|
{getCourseIcon(course.category)}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
className={
|
className={
|
||||||
course.status === "Completed"
|
course.status === "completed"
|
||||||
? "bg-green-500/20 text-green-300 border-green-500/30"
|
? "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-blue-500/20 text-blue-300 border-blue-500/30"
|
||||||
: "bg-slate-700 text-slate-300"
|
: "bg-slate-700 text-slate-300"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{course.status}
|
{course.status === "completed" ? "Completed" : course.status === "in_progress" ? "In Progress" : "Available"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-cyan-100">
|
<CardTitle className="text-cyan-100">
|
||||||
{course.title}
|
{course.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
by {course.instructor}
|
{course.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
@ -259,20 +262,26 @@ export default function StaffLearningPortal() {
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{course.duration}
|
{course.duration_weeks} weeks
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
{course.lessons} lessons
|
{course.lesson_count} lessons
|
||||||
</div>
|
</div>
|
||||||
|
{course.is_required && (
|
||||||
|
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30">
|
||||||
|
Required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full bg-cyan-600 hover:bg-cyan-700"
|
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"
|
? "Review Course"
|
||||||
: course.status === "In Progress"
|
: course.status === "in_progress"
|
||||||
? "Continue"
|
? "Continue"
|
||||||
: "Enroll"}
|
: "Enroll"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -280,6 +289,12 @@ export default function StaffLearningPortal() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-slate-400">No courses found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,86 +18,49 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
Award,
|
Award,
|
||||||
Users,
|
Users,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} 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 {
|
interface Review {
|
||||||
id: string;
|
id: string;
|
||||||
period: string;
|
period: string;
|
||||||
status: "Pending" | "In Progress" | "Completed";
|
status: string;
|
||||||
reviewer?: string;
|
overall_rating?: number;
|
||||||
dueDate: string;
|
reviewer_comments?: string;
|
||||||
feedback?: number;
|
employee_comments?: string;
|
||||||
selfAssessment?: boolean;
|
goals_met?: number;
|
||||||
|
goals_total?: number;
|
||||||
|
due_date: string;
|
||||||
|
created_at: string;
|
||||||
|
reviewer?: {
|
||||||
|
full_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Metric {
|
interface Stats {
|
||||||
name: string;
|
total: number;
|
||||||
score: number;
|
pending: number;
|
||||||
lastQuarter: 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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "Completed":
|
case "completed":
|
||||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
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";
|
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";
|
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
||||||
default:
|
default:
|
||||||
return "bg-slate-500/20 text-slate-300";
|
return "bg-slate-500/20 text-slate-300";
|
||||||
|
|
@ -105,14 +68,71 @@ const getStatusColor = (status: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StaffPerformanceReviews() {
|
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 [selectedReview, setSelectedReview] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [commentDialog, setCommentDialog] = useState<Review | null>(null);
|
||||||
|
const [employeeComments, setEmployeeComments] = useState("");
|
||||||
|
|
||||||
const avgScore =
|
useEffect(() => {
|
||||||
Math.round(
|
if (session?.access_token) {
|
||||||
(performanceMetrics.reduce((sum, m) => sum + m.score, 0) /
|
fetchReviews();
|
||||||
performanceMetrics.length) *
|
}
|
||||||
10,
|
}, [session?.access_token]);
|
||||||
) / 10;
|
|
||||||
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -151,20 +171,20 @@ export default function StaffPerformanceReviews() {
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-purple-200/70 mb-2">
|
<p className="text-sm text-purple-200/70 mb-2">
|
||||||
Overall Rating
|
Average Rating
|
||||||
</p>
|
</p>
|
||||||
<p className="text-5xl font-bold text-purple-100 mb-4">
|
<p className="text-5xl font-bold text-purple-100 mb-4">
|
||||||
{avgScore}
|
{stats.average_rating.toFixed(1)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-400">
|
<p className="text-slate-400">
|
||||||
Based on 5 performance dimensions
|
Based on {stats.completed} completed reviews
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Award className="h-16 w-16 text-purple-400 mx-auto mb-4" />
|
<Award className="h-16 w-16 text-purple-400 mx-auto mb-4" />
|
||||||
<p className="text-sm text-purple-200/70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,39 +192,26 @@ export default function StaffPerformanceReviews() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Performance Metrics */}
|
{/* Stats */}
|
||||||
<div className="mb-12">
|
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||||
<h2 className="text-2xl font-bold text-purple-100 mb-6">
|
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||||
Performance Dimensions
|
<CardContent className="pt-6">
|
||||||
</h2>
|
<p className="text-2xl font-bold text-purple-100">{stats.total}</p>
|
||||||
<div className="space-y-4">
|
<p className="text-sm text-purple-200/70">Total Reviews</p>
|
||||||
{performanceMetrics.map((metric) => (
|
</CardContent>
|
||||||
<Card
|
</Card>
|
||||||
key={metric.name}
|
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||||
className="bg-slate-800/50 border-slate-700/50"
|
<CardContent className="pt-6">
|
||||||
>
|
<p className="text-2xl font-bold text-purple-100">{stats.pending}</p>
|
||||||
<CardContent className="pt-6">
|
<p className="text-sm text-purple-200/70">Pending</p>
|
||||||
<div className="flex items-center justify-between mb-3">
|
</CardContent>
|
||||||
<div>
|
</Card>
|
||||||
<p className="font-semibold text-purple-100">
|
<Card className="bg-purple-950/30 border-purple-500/30">
|
||||||
{metric.name}
|
<CardContent className="pt-6">
|
||||||
</p>
|
<p className="text-2xl font-bold text-purple-100">{stats.completed}</p>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-purple-200/70">Completed</p>
|
||||||
Last quarter: {metric.lastQuarter}
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-purple-300">
|
|
||||||
{metric.score}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={(metric.score / 10) * 100}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Review History */}
|
{/* Review History */}
|
||||||
|
|
@ -213,7 +220,7 @@ export default function StaffPerformanceReviews() {
|
||||||
Review History
|
Review History
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{userReviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<Card
|
<Card
|
||||||
key={review.id}
|
key={review.id}
|
||||||
className="bg-slate-800/50 border-slate-700/50 hover:border-purple-500/50 transition-all cursor-pointer"
|
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
|
{review.period} Review
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
Due: {review.dueDate}
|
Due: {new Date(review.due_date).toLocaleDateString()}
|
||||||
|
{review.reviewer && ` • Reviewer: ${review.reviewer.full_name}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
className={`border ${getStatusColor(review.status)}`}
|
className={`border ${getStatusColor(review.status)}`}
|
||||||
>
|
>
|
||||||
{review.status}
|
{review.status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{selectedReview === review.id && (
|
{selectedReview === review.id && (
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid md:grid-cols-3 gap-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">
|
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||||
<MessageSquare className="h-5 w-5 text-purple-400" />
|
<Award className="h-5 w-5 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-300">
|
<p className="text-sm text-slate-300">Rating</p>
|
||||||
Self Assessment
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-purple-300">
|
<p className="text-sm text-purple-300">
|
||||||
Completed
|
{review.overall_rating}/5
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{review.feedback && (
|
{review.goals_total && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||||
<Users className="h-5 w-5 text-purple-400" />
|
<CheckCircle className="h-5 w-5 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-300">
|
<p className="text-sm text-slate-300">Goals Met</p>
|
||||||
360 Feedback
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-purple-300">
|
<p className="text-sm text-purple-300">
|
||||||
{review.feedback} responses
|
{review.goals_met}/{review.goals_total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{review.status === "Completed" && (
|
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
||||||
<div className="flex items-center gap-3 p-3 bg-slate-700/30 rounded">
|
<MessageSquare className="h-5 w-5 text-purple-400" />
|
||||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
<div>
|
||||||
<div>
|
<p className="text-sm text-slate-300">Your Comments</p>
|
||||||
<p className="text-sm text-slate-300">
|
<p className="text-sm text-purple-300">
|
||||||
Manager Review
|
{review.employee_comments ? "Submitted" : "Not submitted"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-green-300">
|
|
||||||
Completed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
<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 || "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review.employee_comments ? "Edit Comments" : "Add Comments"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-purple-600 hover:bg-purple-700"
|
|
||||||
>
|
|
||||||
View Full Review
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -296,39 +309,44 @@ export default function StaffPerformanceReviews() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Items */}
|
{reviews.length === 0 && (
|
||||||
<Card className="bg-slate-800/50 border-purple-500/30">
|
<div className="text-center py-12">
|
||||||
<CardHeader>
|
<p className="text-slate-400">No reviews found</p>
|
||||||
<CardTitle className="text-purple-100">Next Steps</CardTitle>
|
</div>
|
||||||
</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>
|
|
||||||
</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>
|
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -12,110 +12,189 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
|
||||||
Target,
|
Target,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Zap,
|
|
||||||
Users,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
} from "lucide-react";
|
} 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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
owner: string;
|
status: string;
|
||||||
progress: number;
|
priority: string;
|
||||||
status: "On Track" | "At Risk" | "Completed";
|
due_date?: string;
|
||||||
quarter: string;
|
completed_at?: string;
|
||||||
team: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const okrs: OKR[] = [
|
interface Project {
|
||||||
{
|
id: string;
|
||||||
id: "1",
|
name: string;
|
||||||
title: "Improve Platform Performance by 40%",
|
description: string;
|
||||||
description: "Reduce page load time and increase throughput",
|
status: string;
|
||||||
owner: "Engineering",
|
start_date: string;
|
||||||
progress: 75,
|
end_date?: string;
|
||||||
status: "On Track",
|
lead?: {
|
||||||
quarter: "Q1 2025",
|
full_name: string;
|
||||||
team: "DevOps",
|
avatar_url?: string;
|
||||||
},
|
};
|
||||||
{
|
tasks: Task[];
|
||||||
id: "2",
|
task_stats: {
|
||||||
title: "Expand Creator Network to 5K Members",
|
total: number;
|
||||||
description: "Grow creator base through partnerships and incentives",
|
done: number;
|
||||||
owner: "Community",
|
};
|
||||||
progress: 62,
|
}
|
||||||
status: "On Track",
|
|
||||||
quarter: "Q1 2025",
|
interface Stats {
|
||||||
team: "Growth",
|
total: number;
|
||||||
},
|
active: number;
|
||||||
{
|
completed: number;
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "On Track":
|
case "active":
|
||||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||||
case "At Risk":
|
case "completed":
|
||||||
return "bg-amber-500/20 text-amber-300 border-amber-500/30";
|
|
||||||
case "Completed":
|
|
||||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
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:
|
default:
|
||||||
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
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() {
|
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
|
const fetchProjects = async () => {
|
||||||
? okrs.filter((okr) => okr.team === selectedTeam)
|
try {
|
||||||
: okrs;
|
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 =
|
const updateTaskStatus = async (taskId: string, status: string) => {
|
||||||
Math.round(
|
try {
|
||||||
filtered.reduce((sum, okr) => sum + okr.progress, 0) / filtered.length,
|
const res = await fetch("/api/staff/projects", {
|
||||||
) || 0;
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<SEO
|
<SEO
|
||||||
title="Project Tracking"
|
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">
|
<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
|
Project Tracking
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-indigo-200/70">
|
<p className="text-indigo-200/70">
|
||||||
OKRs, initiatives, and company-wide roadmap
|
Your projects, tasks, and progress
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,9 +227,9 @@ export default function StaffProjectTracking() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-3xl font-bold text-indigo-100">
|
||||||
{filtered.length}
|
{stats.total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Target className="h-8 w-8 text-indigo-400" />
|
<Target className="h-8 w-8 text-indigo-400" />
|
||||||
|
|
@ -174,9 +253,9 @@ export default function StaffProjectTracking() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-3xl font-bold text-indigo-100">
|
||||||
{filtered.filter((o) => o.status === "On Track").length}
|
{stats.active}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-indigo-400" />
|
<CheckCircle className="h-8 w-8 text-indigo-400" />
|
||||||
|
|
@ -185,93 +264,180 @@ export default function StaffProjectTracking() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Filter */}
|
{/* Projects */}
|
||||||
<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 */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{filtered.map((okr) => (
|
{projects.map((project) => (
|
||||||
<Card
|
<Card
|
||||||
key={okr.id}
|
key={project.id}
|
||||||
className="bg-slate-800/50 border-slate-700/50 hover:border-indigo-500/50 transition-all"
|
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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="text-indigo-100">
|
<CardTitle className="text-indigo-100">
|
||||||
{okr.title}
|
{project.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{okr.description}
|
{project.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`border ${getStatusColor(okr.status)}`}>
|
<Badge className={`border ${getStatusColor(project.status)}`}>
|
||||||
{okr.status}
|
{project.status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<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">
|
<span className="text-indigo-300 font-semibold">
|
||||||
{okr.progress}%
|
{project.task_stats.done}/{project.task_stats.total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<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">Lead</p>
|
||||||
|
<p className="text-indigo-300">{project.lead.full_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">Owner</p>
|
<p className="text-xs text-slate-500">Start Date</p>
|
||||||
<p className="text-sm text-indigo-300">{okr.owner}</p>
|
<p className="text-indigo-300">{new Date(project.start_date).toLocaleDateString()}</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Quarter</p>
|
|
||||||
<p className="text-sm text-indigo-300">{okr.quarter}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Team</p>
|
|
||||||
<p className="text-sm text-indigo-300">{okr.team}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{project.end_date && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">End Date</p>
|
||||||
|
<p className="text-indigo-300">{new Date(project.end_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-slate-400">No projects found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import SEO from "@/components/SEO";
|
import SEO from "@/components/SEO";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -11,94 +12,88 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Heart,
|
Heart,
|
||||||
DollarSign,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Zap,
|
Zap,
|
||||||
Award,
|
Award,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||||
|
|
||||||
interface HandbookSection {
|
interface HandbookSection {
|
||||||
id: string;
|
id: string;
|
||||||
|
category: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
|
||||||
content: string;
|
content: string;
|
||||||
subsections: string[];
|
order_index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections: HandbookSection[] = [
|
const getCategoryIcon = (category: string) => {
|
||||||
{
|
switch (category) {
|
||||||
id: "1",
|
case "Benefits":
|
||||||
title: "Benefits & Compensation",
|
return <Heart className="h-6 w-6" />;
|
||||||
icon: <Heart className="h-6 w-6" />,
|
case "Policies":
|
||||||
content: "Comprehensive benefits package including health, dental, vision",
|
return <Shield className="h-6 w-6" />;
|
||||||
subsections: [
|
case "Time Off":
|
||||||
"Health Insurance",
|
return <Calendar className="h-6 w-6" />;
|
||||||
"Retirement Plans",
|
case "Remote Work":
|
||||||
"Stock Options",
|
return <MapPin className="h-6 w-6" />;
|
||||||
"Flexible PTO",
|
case "Development":
|
||||||
],
|
return <Zap className="h-6 w-6" />;
|
||||||
},
|
case "Recognition":
|
||||||
{
|
return <Award className="h-6 w-6" />;
|
||||||
id: "2",
|
default:
|
||||||
title: "Company Policies",
|
return <Users className="h-6 w-6" />;
|
||||||
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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function StaffTeamHandbook() {
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<SEO
|
<SEO
|
||||||
|
|
@ -158,50 +153,67 @@ export default function StaffTeamHandbook() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Handbook Sections */}
|
{/* Handbook Sections by Category */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{sections.map((section) => (
|
{categories.map((category) => (
|
||||||
<Card
|
<Card
|
||||||
key={section.id}
|
key={category}
|
||||||
className="bg-slate-800/50 border-slate-700/50 hover:border-blue-500/50 transition-all"
|
className="bg-slate-800/50 border-slate-700/50 hover:border-blue-500/50 transition-all"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<div className="flex items-start justify-between">
|
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="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/20 text-blue-400">
|
<div className="p-2 rounded-lg bg-blue-500/20 text-blue-400">
|
||||||
{section.icon}
|
{getCategoryIcon(category)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-blue-100">
|
<CardTitle className="text-blue-100">
|
||||||
{section.title}
|
{category}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{section.content}
|
{grouped[category]?.length || 0} sections
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expandedCategory === category ? (
|
||||||
|
<ChevronUp className="h-5 w-5 text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-5 w-5 text-blue-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
{expandedCategory === category && (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<CardContent className="pt-0">
|
||||||
{section.subsections.map((subsection) => (
|
<div className="space-y-4 pl-14">
|
||||||
<Badge
|
{grouped[category]?.map((section) => (
|
||||||
key={subsection}
|
<div
|
||||||
variant="secondary"
|
key={section.id}
|
||||||
className="bg-slate-700/50 text-slate-300"
|
className="p-4 bg-slate-700/30 rounded-lg"
|
||||||
>
|
>
|
||||||
{subsection}
|
<h4 className="font-semibold text-blue-100 mb-2">
|
||||||
</Badge>
|
{section.title}
|
||||||
))}
|
</h4>
|
||||||
</div>
|
<p className="text-slate-300 text-sm whitespace-pre-line">
|
||||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
{section.content}
|
||||||
View Details
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
</CardContent>
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-slate-400">No handbook sections found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Additional Resources */}
|
{/* Additional Resources */}
|
||||||
<div className="mt-12 p-6 rounded-lg bg-slate-800/50 border border-blue-500/30">
|
<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">
|
<h2 className="text-xl font-bold text-blue-100 mb-4">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue