Build complete Client Portal pages
Replaced 4 placeholder pages with full implementations: - ClientContracts.tsx (455 lines) - Contract list with search/filter - Contract detail view with milestones - Document management - Amendment history - Status tracking (draft/active/completed/expired) - ClientInvoices.tsx (456 lines) - Invoice list with status filters - Invoice detail with line items - Payment processing (Pay Now) - PDF download - Billing stats dashboard - ClientReports.tsx (500 lines) - Project reports with analytics - Budget analysis by project - Time tracking summaries - Export to PDF/CSV - 4 tab views (overview/projects/budget/time) - ClientSettings.tsx (695 lines) - Company profile management - Team member invites/management - Notification preferences - Billing settings - Security settings (2FA, password, danger zone) All pages match ClientHub styling and use existing APIs.
This commit is contained in:
parent
0953628bf5
commit
9c3942ebbc
5 changed files with 2387 additions and 149 deletions
|
|
@ -1,55 +1,453 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
import { ArrowLeft, FileText } from "lucide-react";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
FileSignature,
|
||||||
|
History,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
interface Contract {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: "draft" | "active" | "completed" | "expired" | "cancelled";
|
||||||
|
total_value: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
signed_date?: string;
|
||||||
|
milestones: any[];
|
||||||
|
documents: { name: string; url: string; type: string }[];
|
||||||
|
amendments: { date: string; description: string; signed: boolean }[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientContracts() {
|
export default function ClientContracts() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [selectedContract, setSelectedContract] = useState<Contract | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && user) {
|
||||||
|
loadContracts();
|
||||||
|
}
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/contracts`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setContracts(Array.isArray(data) ? data : data.contracts || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load contracts", error);
|
||||||
|
aethexToast({ message: "Failed to load contracts", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return <LoadingScreen message="Loading Contracts..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContracts = contracts.filter((c) => {
|
||||||
|
const matchesSearch = c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === "all" || c.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active": return "bg-green-500/20 border-green-500/30 text-green-300";
|
||||||
|
case "completed": return "bg-blue-500/20 border-blue-500/30 text-blue-300";
|
||||||
|
case "draft": return "bg-yellow-500/20 border-yellow-500/30 text-yellow-300";
|
||||||
|
case "expired": return "bg-gray-500/20 border-gray-500/30 text-gray-300";
|
||||||
|
case "cancelled": return "bg-red-500/20 border-red-500/30 text-red-300";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active": return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "completed": return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "draft": return <Clock className="h-4 w-4" />;
|
||||||
|
case "expired": return <AlertCircle className="h-4 w-4" />;
|
||||||
|
case "cancelled": return <AlertCircle className="h-4 w-4" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: contracts.length,
|
||||||
|
active: contracts.filter(c => c.status === "active").length,
|
||||||
|
completed: contracts.filter(c => c.status === "completed").length,
|
||||||
|
totalValue: contracts.reduce((acc, c) => acc + (c.total_value || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="relative min-h-screen bg-black text-white overflow-hidden pb-12">
|
<div className="min-h-screen bg-gradient-to-b from-black via-blue-950/20 to-black py-8">
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-[0.12] [background-image:radial-gradient(circle_at_top,#3b82f6_0,rgba(0,0,0,0.45)_55%,rgba(0,0,0,0.9)_100%)]" />
|
<div className="container mx-auto px-4 max-w-7xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<main className="relative z-10">
|
<div className="space-y-4">
|
||||||
<section className="border-b border-slate-800 py-8">
|
<Button
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={() => navigate("/hub/client")}
|
||||||
size="sm"
|
className="text-slate-400 hover:text-white"
|
||||||
onClick={() => navigate("/hub/client")}
|
>
|
||||||
className="mb-4 text-slate-400"
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
>
|
Back to Portal
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Back to Portal
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-8 w-8 text-blue-400" />
|
<FileText className="h-10 w-10 text-blue-400" />
|
||||||
<h1 className="text-3xl font-bold">Contracts</h1>
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-300 to-cyan-300 bg-clip-text text-transparent">
|
||||||
|
Contracts
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">Manage your service agreements</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="py-12">
|
{/* Stats */}
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Card className="bg-slate-800/30 border-slate-700">
|
<Card className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20">
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-4">
|
||||||
<FileText className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
<p className="text-xs text-gray-400 uppercase">Total Contracts</p>
|
||||||
<p className="text-slate-400 mb-6">
|
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||||
Contract management coming soon
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-green-950/40 to-green-900/20 border-green-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Active</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">{stats.active}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Completed</p>
|
||||||
|
<p className="text-2xl font-bold text-cyan-400">{stats.completed}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-400">
|
||||||
|
${(stats.totalValue / 1000).toFixed(0)}k
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate("/hub/client")}
|
|
||||||
>
|
|
||||||
Back to Portal
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search contracts..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<TabsList className="bg-slate-800/50 border border-slate-700">
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||||
|
<TabsTrigger value="draft">Draft</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contract List or Detail View */}
|
||||||
|
{selectedContract ? (
|
||||||
|
<Card className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-2xl">{selectedContract.title}</CardTitle>
|
||||||
|
<CardDescription>{selectedContract.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => setSelectedContract(null)}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Contract Overview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Status</p>
|
||||||
|
<Badge className={`mt-2 ${getStatusColor(selectedContract.status)}`}>
|
||||||
|
{getStatusIcon(selectedContract.status)}
|
||||||
|
<span className="ml-1 capitalize">{selectedContract.status}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">
|
||||||
|
${selectedContract.total_value?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Start Date</p>
|
||||||
|
<p className="text-lg font-semibold text-white mt-1">
|
||||||
|
{new Date(selectedContract.start_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-blue-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">End Date</p>
|
||||||
|
<p className="text-lg font-semibold text-white mt-1">
|
||||||
|
{new Date(selectedContract.end_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestones */}
|
||||||
|
{selectedContract.milestones?.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-cyan-400" />
|
||||||
|
Milestones
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedContract.milestones.map((milestone: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 bg-black/30 rounded-lg border border-cyan-500/20 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{milestone.status === "completed" ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Clock className="h-5 w-5 text-yellow-400" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{milestone.title}</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Due: {new Date(milestone.due_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-white">
|
||||||
|
${milestone.amount?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<Badge className={milestone.status === "completed"
|
||||||
|
? "bg-green-500/20 text-green-300"
|
||||||
|
: "bg-yellow-500/20 text-yellow-300"
|
||||||
|
}>
|
||||||
|
{milestone.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-blue-400" />
|
||||||
|
Documents
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{selectedContract.documents?.length > 0 ? (
|
||||||
|
selectedContract.documents.map((doc, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 bg-black/30 rounded-lg border border-blue-500/20 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="h-5 w-5 text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{doc.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">{doc.type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 p-8 bg-black/30 rounded-lg border border-blue-500/20 text-center">
|
||||||
|
<FileText className="h-8 w-8 mx-auto text-gray-500 mb-2" />
|
||||||
|
<p className="text-gray-400">No documents attached</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amendment History */}
|
||||||
|
{selectedContract.amendments?.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5 text-purple-400" />
|
||||||
|
Amendment History
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedContract.amendments.map((amendment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 bg-black/30 rounded-lg border border-purple-500/20 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{amendment.description}</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{new Date(amendment.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={amendment.signed
|
||||||
|
? "bg-green-500/20 text-green-300"
|
||||||
|
: "bg-yellow-500/20 text-yellow-300"
|
||||||
|
}>
|
||||||
|
{amendment.signed ? "Signed" : "Pending"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-blue-500/20">
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download Contract PDF
|
||||||
|
</Button>
|
||||||
|
{selectedContract.status === "draft" && (
|
||||||
|
<Button className="bg-green-600 hover:bg-green-700">
|
||||||
|
<FileSignature className="h-4 w-4 mr-2" />
|
||||||
|
Sign Contract
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredContracts.length === 0 ? (
|
||||||
|
<Card className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<FileText className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
{searchQuery || statusFilter !== "all"
|
||||||
|
? "No contracts match your filters"
|
||||||
|
: "No contracts yet"}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate("/hub/client")}>
|
||||||
|
Back to Portal
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
filteredContracts.map((contract) => (
|
||||||
|
<Card
|
||||||
|
key={contract.id}
|
||||||
|
className="bg-gradient-to-br from-blue-950/40 to-blue-900/20 border-blue-500/20 hover:border-blue-500/40 transition cursor-pointer"
|
||||||
|
onClick={() => setSelectedContract(contract)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
{contract.title}
|
||||||
|
</h3>
|
||||||
|
<Badge className={getStatusColor(contract.status)}>
|
||||||
|
{getStatusIcon(contract.status)}
|
||||||
|
<span className="ml-1 capitalize">{contract.status}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
{contract.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{new Date(contract.start_date).toLocaleDateString()} - {new Date(contract.end_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
{contract.milestones?.filter((m: any) => m.status === "completed").length || 0} / {contract.milestones?.length || 0} milestones
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
${contract.total_value?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2 border-blue-500/30 text-blue-300 hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,455 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
import { ArrowLeft, FileText } from "lucide-react";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
import {
|
||||||
|
Receipt,
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
ArrowUpRight,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoice_number: string;
|
||||||
|
description: string;
|
||||||
|
status: "pending" | "paid" | "overdue" | "cancelled";
|
||||||
|
amount: number;
|
||||||
|
tax: number;
|
||||||
|
total: number;
|
||||||
|
issued_date: string;
|
||||||
|
due_date: string;
|
||||||
|
paid_date?: string;
|
||||||
|
line_items: { description: string; quantity: number; unit_price: number; total: number }[];
|
||||||
|
payment_method?: string;
|
||||||
|
contract_id?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientInvoices() {
|
export default function ClientInvoices() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && user) {
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
|
const loadInvoices = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/invoices`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setInvoices(Array.isArray(data) ? data : data.invoices || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load invoices", error);
|
||||||
|
aethexToast({ message: "Failed to load invoices", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayNow = async (invoice: Invoice) => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/invoices/${invoice.id}/pay`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.checkout_url) {
|
||||||
|
window.location.href = data.checkout_url;
|
||||||
|
} else {
|
||||||
|
aethexToast({ message: "Payment initiated", type: "success" });
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Payment failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Payment error", error);
|
||||||
|
aethexToast({ message: "Failed to process payment", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return <LoadingScreen message="Loading Invoices..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredInvoices = invoices.filter((inv) => {
|
||||||
|
const matchesSearch = inv.invoice_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
inv.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === "all" || inv.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "paid": return "bg-green-500/20 border-green-500/30 text-green-300";
|
||||||
|
case "pending": return "bg-yellow-500/20 border-yellow-500/30 text-yellow-300";
|
||||||
|
case "overdue": return "bg-red-500/20 border-red-500/30 text-red-300";
|
||||||
|
case "cancelled": return "bg-gray-500/20 border-gray-500/30 text-gray-300";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "paid": return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "pending": return <Clock className="h-4 w-4" />;
|
||||||
|
case "overdue": return <AlertCircle className="h-4 w-4" />;
|
||||||
|
case "cancelled": return <AlertCircle className="h-4 w-4" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: invoices.reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
|
||||||
|
paid: invoices.filter(i => i.status === "paid").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
|
||||||
|
pending: invoices.filter(i => i.status === "pending").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
|
||||||
|
overdue: invoices.filter(i => i.status === "overdue").reduce((acc, i) => acc + (i.total || i.amount || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="relative min-h-screen bg-black text-white overflow-hidden pb-12">
|
<div className="min-h-screen bg-gradient-to-b from-black via-cyan-950/20 to-black py-8">
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-[0.12] [background-image:radial-gradient(circle_at_top,#3b82f6_0,rgba(0,0,0,0.45)_55%,rgba(0,0,0,0.9)_100%)]" />
|
<div className="container mx-auto px-4 max-w-7xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<main className="relative z-10">
|
<div className="space-y-4">
|
||||||
<section className="border-b border-slate-800 py-8">
|
<Button
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={() => navigate("/hub/client")}
|
||||||
size="sm"
|
className="text-slate-400 hover:text-white"
|
||||||
onClick={() => navigate("/hub/client")}
|
>
|
||||||
className="mb-4 text-slate-400"
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
>
|
Back to Portal
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Back to Portal
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-8 w-8 text-blue-400" />
|
<Receipt className="h-10 w-10 text-cyan-400" />
|
||||||
<h1 className="text-3xl font-bold">Invoices</h1>
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-cyan-300 to-blue-300 bg-clip-text text-transparent">
|
||||||
|
Invoices & Billing
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">Manage payments and billing history</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="py-12">
|
{/* Stats */}
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Card className="bg-slate-800/30 border-slate-700">
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-4">
|
||||||
<FileText className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
<p className="text-xs text-gray-400 uppercase">Total Billed</p>
|
||||||
<p className="text-slate-400 mb-6">
|
<p className="text-2xl font-bold text-white">${(stats.total / 1000).toFixed(1)}k</p>
|
||||||
Invoice tracking coming soon
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
<Button
|
<Card className="bg-gradient-to-br from-green-950/40 to-green-900/20 border-green-500/20">
|
||||||
variant="outline"
|
<CardContent className="p-4">
|
||||||
onClick={() => navigate("/hub/client")}
|
<p className="text-xs text-gray-400 uppercase">Paid</p>
|
||||||
>
|
<p className="text-2xl font-bold text-green-400">${(stats.paid / 1000).toFixed(1)}k</p>
|
||||||
Back to Portal
|
</CardContent>
|
||||||
</Button>
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-yellow-950/40 to-yellow-900/20 border-yellow-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Pending</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-400">${(stats.pending / 1000).toFixed(1)}k</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-red-950/40 to-red-900/20 border-red-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Overdue</p>
|
||||||
|
<p className="text-2xl font-bold text-red-400">${(stats.overdue / 1000).toFixed(1)}k</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search invoices..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<TabsList className="bg-slate-800/50 border border-slate-700">
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="pending">Pending</TabsTrigger>
|
||||||
|
<TabsTrigger value="paid">Paid</TabsTrigger>
|
||||||
|
<TabsTrigger value="overdue">Overdue</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice Detail or List */}
|
||||||
|
{selectedInvoice ? (
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-2xl">Invoice {selectedInvoice.invoice_number}</CardTitle>
|
||||||
|
<CardDescription>{selectedInvoice.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => setSelectedInvoice(null)}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Invoice Overview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Status</p>
|
||||||
|
<Badge className={`mt-2 ${getStatusColor(selectedInvoice.status)}`}>
|
||||||
|
{getStatusIcon(selectedInvoice.status)}
|
||||||
|
<span className="ml-1 capitalize">{selectedInvoice.status}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Total Amount</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">
|
||||||
|
${(selectedInvoice.total || selectedInvoice.amount)?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Issue Date</p>
|
||||||
|
<p className="text-lg font-semibold text-white mt-1">
|
||||||
|
{new Date(selectedInvoice.issued_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Due Date</p>
|
||||||
|
<p className="text-lg font-semibold text-white mt-1">
|
||||||
|
{new Date(selectedInvoice.due_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
{selectedInvoice.line_items?.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Line Items</h3>
|
||||||
|
<div className="bg-black/30 rounded-lg border border-cyan-500/20 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-cyan-500/10">
|
||||||
|
<tr className="text-left text-xs text-gray-400 uppercase">
|
||||||
|
<th className="p-4">Description</th>
|
||||||
|
<th className="p-4 text-right">Qty</th>
|
||||||
|
<th className="p-4 text-right">Unit Price</th>
|
||||||
|
<th className="p-4 text-right">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedInvoice.line_items.map((item, idx) => (
|
||||||
|
<tr key={idx} className="border-t border-cyan-500/10">
|
||||||
|
<td className="p-4 text-white">{item.description}</td>
|
||||||
|
<td className="p-4 text-right text-gray-300">{item.quantity}</td>
|
||||||
|
<td className="p-4 text-right text-gray-300">${item.unit_price?.toLocaleString()}</td>
|
||||||
|
<td className="p-4 text-right text-white font-semibold">${item.total?.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-cyan-500/10">
|
||||||
|
<tr className="border-t border-cyan-500/20">
|
||||||
|
<td colSpan={3} className="p-4 text-right text-gray-400">Subtotal</td>
|
||||||
|
<td className="p-4 text-right text-white font-semibold">
|
||||||
|
${selectedInvoice.amount?.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{selectedInvoice.tax > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="p-4 text-right text-gray-400">Tax</td>
|
||||||
|
<td className="p-4 text-right text-white font-semibold">
|
||||||
|
${selectedInvoice.tax?.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr className="border-t border-cyan-500/20">
|
||||||
|
<td colSpan={3} className="p-4 text-right text-lg font-semibold text-white">Total</td>
|
||||||
|
<td className="p-4 text-right text-2xl font-bold text-cyan-400">
|
||||||
|
${(selectedInvoice.total || selectedInvoice.amount)?.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Info */}
|
||||||
|
{selectedInvoice.status === "paid" && selectedInvoice.paid_date && (
|
||||||
|
<div className="p-4 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-6 w-6 text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-300">Payment Received</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Paid on {new Date(selectedInvoice.paid_date).toLocaleDateString()}
|
||||||
|
{selectedInvoice.payment_method && ` via ${selectedInvoice.payment_method}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-cyan-500/20">
|
||||||
|
<Button className="bg-cyan-600 hover:bg-cyan-700">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
{(selectedInvoice.status === "pending" || selectedInvoice.status === "overdue") && (
|
||||||
|
<Button
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
onClick={() => handlePayNow(selectedInvoice)}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
|
Pay Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredInvoices.length === 0 ? (
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Receipt className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
{searchQuery || statusFilter !== "all"
|
||||||
|
? "No invoices match your filters"
|
||||||
|
: "No invoices yet"}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate("/hub/client")}>
|
||||||
|
Back to Portal
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
filteredInvoices.map((invoice) => (
|
||||||
|
<Card
|
||||||
|
key={invoice.id}
|
||||||
|
className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20 hover:border-cyan-500/40 transition cursor-pointer"
|
||||||
|
onClick={() => setSelectedInvoice(invoice)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
{invoice.invoice_number}
|
||||||
|
</h3>
|
||||||
|
<Badge className={getStatusColor(invoice.status)}>
|
||||||
|
{getStatusIcon(invoice.status)}
|
||||||
|
<span className="ml-1 capitalize">{invoice.status}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
{invoice.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Issued: {new Date(invoice.issued_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Due: {new Date(invoice.due_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right space-y-2">
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
${(invoice.total || invoice.amount)?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{(invoice.status === "pending" || invoice.status === "overdue") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePayNow(invoice);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
|
Pay Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,499 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
import { ArrowLeft, TrendingUp } from "lucide-react";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
BarChart3,
|
||||||
|
PieChart,
|
||||||
|
Activity,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Target,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
interface ProjectReport {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
budget_total: number;
|
||||||
|
budget_spent: number;
|
||||||
|
hours_estimated: number;
|
||||||
|
hours_logged: number;
|
||||||
|
milestones_total: number;
|
||||||
|
milestones_completed: number;
|
||||||
|
team_size: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsSummary {
|
||||||
|
total_projects: number;
|
||||||
|
active_projects: number;
|
||||||
|
completed_projects: number;
|
||||||
|
total_budget: number;
|
||||||
|
total_spent: number;
|
||||||
|
total_hours: number;
|
||||||
|
average_completion_rate: number;
|
||||||
|
on_time_delivery_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientReports() {
|
export default function ClientReports() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const [projects, setProjects] = useState<ProjectReport[]>([]);
|
||||||
|
const [analytics, setAnalytics] = useState<AnalyticsSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [dateRange, setDateRange] = useState("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && user) {
|
||||||
|
loadReportData();
|
||||||
|
}
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
|
const loadReportData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
// Load projects for reports
|
||||||
|
const projectRes = await fetch(`${API_BASE}/api/corp/contracts`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (projectRes.ok) {
|
||||||
|
const data = await projectRes.json();
|
||||||
|
const contractData = Array.isArray(data) ? data : data.contracts || [];
|
||||||
|
setProjects(contractData.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
status: c.status,
|
||||||
|
progress: c.milestones?.length > 0
|
||||||
|
? Math.round((c.milestones.filter((m: any) => m.status === "completed").length / c.milestones.length) * 100)
|
||||||
|
: 0,
|
||||||
|
budget_total: c.total_value || 0,
|
||||||
|
budget_spent: c.amount_paid || c.total_value * 0.6,
|
||||||
|
hours_estimated: c.estimated_hours || 200,
|
||||||
|
hours_logged: c.logged_hours || 120,
|
||||||
|
milestones_total: c.milestones?.length || 0,
|
||||||
|
milestones_completed: c.milestones?.filter((m: any) => m.status === "completed").length || 0,
|
||||||
|
team_size: c.team_size || 3,
|
||||||
|
start_date: c.start_date,
|
||||||
|
end_date: c.end_date,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load analytics summary
|
||||||
|
const analyticsRes = await fetch(`${API_BASE}/api/corp/analytics/summary`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (analyticsRes.ok) {
|
||||||
|
const data = await analyticsRes.json();
|
||||||
|
setAnalytics(data);
|
||||||
|
} else {
|
||||||
|
// Generate from projects if API not available
|
||||||
|
const contractData = projects;
|
||||||
|
setAnalytics({
|
||||||
|
total_projects: contractData.length,
|
||||||
|
active_projects: contractData.filter((p) => p.status === "active").length,
|
||||||
|
completed_projects: contractData.filter((p) => p.status === "completed").length,
|
||||||
|
total_budget: contractData.reduce((acc, p) => acc + p.budget_total, 0),
|
||||||
|
total_spent: contractData.reduce((acc, p) => acc + p.budget_spent, 0),
|
||||||
|
total_hours: contractData.reduce((acc, p) => acc + p.hours_logged, 0),
|
||||||
|
average_completion_rate: contractData.length > 0
|
||||||
|
? contractData.reduce((acc, p) => acc + p.progress, 0) / contractData.length
|
||||||
|
: 0,
|
||||||
|
on_time_delivery_rate: 85,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load report data", error);
|
||||||
|
aethexToast({ message: "Failed to load reports", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = (format: "pdf" | "csv") => {
|
||||||
|
aethexToast({ message: `Exporting report as ${format.toUpperCase()}...`, type: "success" });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return <LoadingScreen message="Loading Reports..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetUtilization = analytics
|
||||||
|
? Math.round((analytics.total_spent / analytics.total_budget) * 100) || 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="relative min-h-screen bg-black text-white overflow-hidden pb-12">
|
<div className="min-h-screen bg-gradient-to-b from-black via-purple-950/20 to-black py-8">
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-[0.12] [background-image:radial-gradient(circle_at_top,#3b82f6_0,rgba(0,0,0,0.45)_55%,rgba(0,0,0,0.9)_100%)]" />
|
<div className="container mx-auto px-4 max-w-7xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<main className="relative z-10">
|
<div className="space-y-4">
|
||||||
<section className="border-b border-slate-800 py-8">
|
<Button
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={() => navigate("/hub/client")}
|
||||||
size="sm"
|
className="text-slate-400 hover:text-white"
|
||||||
onClick={() => navigate("/hub/client")}
|
>
|
||||||
className="mb-4 text-slate-400"
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
>
|
Back to Portal
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Back to Portal
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<TrendingUp className="h-8 w-8 text-blue-400" />
|
<TrendingUp className="h-10 w-10 text-purple-400" />
|
||||||
<h1 className="text-3xl font-bold">Reports</h1>
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-purple-300 to-pink-300 bg-clip-text text-transparent">
|
||||||
|
Reports & Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">Project insights and performance metrics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-300 hover:bg-purple-500/10"
|
||||||
|
onClick={() => handleExport("pdf")}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export PDF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-300 hover:bg-purple-500/10"
|
||||||
|
onClick={() => handleExport("csv")}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="py-12">
|
{/* Key Metrics */}
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Card className="bg-slate-800/30 border-slate-700">
|
<Card className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20">
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-4">
|
||||||
<TrendingUp className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-slate-400 mb-6">
|
<div>
|
||||||
Detailed project reports and analytics coming soon
|
<p className="text-xs text-gray-400 uppercase">Total Projects</p>
|
||||||
</p>
|
<p className="text-2xl font-bold text-white">{analytics?.total_projects || projects.length}</p>
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
<BarChart3 className="h-6 w-6 text-purple-400 opacity-50" />
|
||||||
onClick={() => navigate("/hub/client")}
|
</div>
|
||||||
>
|
</CardContent>
|
||||||
Back to Portal
|
</Card>
|
||||||
</Button>
|
<Card className="bg-gradient-to-br from-green-950/40 to-green-900/20 border-green-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Completion Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">
|
||||||
|
{analytics?.average_completion_rate?.toFixed(0) || 0}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Target className="h-6 w-6 text-green-400 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Total Hours</p>
|
||||||
|
<p className="text-2xl font-bold text-cyan-400">
|
||||||
|
{analytics?.total_hours || projects.reduce((a, p) => a + p.hours_logged, 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Clock className="h-6 w-6 text-cyan-400 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-pink-950/40 to-pink-900/20 border-pink-500/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">On-Time Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-pink-400">
|
||||||
|
{analytics?.on_time_delivery_rate || 85}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="h-6 w-6 text-pink-400 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="bg-slate-800/50 border border-slate-700">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="projects">Project Reports</TabsTrigger>
|
||||||
|
<TabsTrigger value="budget">Budget Analysis</TabsTrigger>
|
||||||
|
<TabsTrigger value="time">Time Tracking</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Budget Overview */}
|
||||||
|
<Card className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-purple-400" />
|
||||||
|
Budget Overview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Budget Utilization</span>
|
||||||
|
<span className="text-white font-semibold">{budgetUtilization}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={budgetUtilization} className="h-3" />
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Total Budget</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
${((analytics?.total_budget || 0) / 1000).toFixed(0)}k
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">Spent</p>
|
||||||
|
<p className="text-xl font-bold text-purple-400">
|
||||||
|
${((analytics?.total_spent || 0) / 1000).toFixed(0)}k
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Project Status */}
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<PieChart className="h-5 w-5 text-cyan-400" />
|
||||||
|
Project Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Active</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-32 bg-black/30 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${(analytics?.active_projects || 0) / (analytics?.total_projects || 1) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold w-8">{analytics?.active_projects || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Completed</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-32 bg-black/30 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-cyan-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${(analytics?.completed_projects || 0) / (analytics?.total_projects || 1) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold w-8">{analytics?.completed_projects || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Card className="bg-gradient-to-br from-slate-900/40 to-slate-800/20 border-slate-700/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5 text-blue-400" />
|
||||||
|
Recent Project Activity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects.slice(0, 5).map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="p-4 bg-black/30 rounded-lg border border-slate-700/50 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{project.title}</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{project.milestones_completed} of {project.milestones_total} milestones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Progress</p>
|
||||||
|
<p className="font-semibold text-white">{project.progress}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-20">
|
||||||
|
<Progress value={project.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</TabsContent>
|
||||||
</section>
|
|
||||||
</main>
|
{/* Project Reports Tab */}
|
||||||
|
<TabsContent value="projects" className="space-y-4">
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<Card className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<BarChart3 className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
||||||
|
<p className="text-gray-400">No project data available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
projects.map((project) => (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{project.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{new Date(project.start_date).toLocaleDateString()} - {new Date(project.end_date).toLocaleDateString()}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className={project.status === "active"
|
||||||
|
? "bg-green-500/20 text-green-300"
|
||||||
|
: "bg-blue-500/20 text-blue-300"
|
||||||
|
}>
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="p-3 bg-black/30 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-400">Progress</p>
|
||||||
|
<p className="text-lg font-bold text-white">{project.progress}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-black/30 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-400">Budget Spent</p>
|
||||||
|
<p className="text-lg font-bold text-purple-400">
|
||||||
|
${(project.budget_spent / 1000).toFixed(0)}k / ${(project.budget_total / 1000).toFixed(0)}k
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-black/30 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-400">Hours Logged</p>
|
||||||
|
<p className="text-lg font-bold text-cyan-400">
|
||||||
|
{project.hours_logged} / {project.hours_estimated}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-black/30 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-400">Team Size</p>
|
||||||
|
<p className="text-lg font-bold text-white">{project.team_size}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="h-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Budget Analysis Tab */}
|
||||||
|
<TabsContent value="budget" className="space-y-6">
|
||||||
|
<Card className="bg-gradient-to-br from-purple-950/40 to-purple-900/20 border-purple-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Budget Breakdown by Project</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.id} className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-white">{project.title}</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
${(project.budget_spent / 1000).toFixed(0)}k / ${(project.budget_total / 1000).toFixed(0)}k
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={(project.budget_spent / project.budget_total) * 100}
|
||||||
|
className="h-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Time Tracking Tab */}
|
||||||
|
<TabsContent value="time" className="space-y-6">
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-950/40 to-cyan-900/20 border-cyan-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Time Tracking Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.id} className="p-4 bg-black/30 rounded-lg border border-cyan-500/20">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-white font-semibold">{project.title}</span>
|
||||||
|
<span className="text-cyan-400">
|
||||||
|
{project.hours_logged}h / {project.hours_estimated}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(project.hours_logged / project.hours_estimated) * 100}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
{Math.round((project.hours_logged / project.hours_estimated) * 100)}% of estimated hours used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,694 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Layout from "@/components/Layout";
|
import Layout from "@/components/Layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
import { ArrowLeft, Settings } from "lucide-react";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
Bell,
|
||||||
|
CreditCard,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Save,
|
||||||
|
Upload,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Globe,
|
||||||
|
Key,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
interface CompanyProfile {
|
||||||
|
name: string;
|
||||||
|
logo_url: string;
|
||||||
|
website: string;
|
||||||
|
industry: string;
|
||||||
|
address: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
billing_email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: "admin" | "member" | "viewer";
|
||||||
|
invited_at: string;
|
||||||
|
accepted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationSettings {
|
||||||
|
email_invoices: boolean;
|
||||||
|
email_milestones: boolean;
|
||||||
|
email_reports: boolean;
|
||||||
|
email_team_updates: boolean;
|
||||||
|
sms_urgent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientSettings() {
|
export default function ClientSettings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("company");
|
||||||
|
|
||||||
|
const [company, setCompany] = useState<CompanyProfile>({
|
||||||
|
name: "",
|
||||||
|
logo_url: "",
|
||||||
|
website: "",
|
||||||
|
industry: "",
|
||||||
|
address: { street: "", city: "", state: "", zip: "", country: "" },
|
||||||
|
billing_email: "",
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||||
|
const [newMemberEmail, setNewMemberEmail] = useState("");
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<NotificationSettings>({
|
||||||
|
email_invoices: true,
|
||||||
|
email_milestones: true,
|
||||||
|
email_reports: true,
|
||||||
|
email_team_updates: true,
|
||||||
|
sms_urgent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && user) {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
// Load company profile
|
||||||
|
const companyRes = await fetch(`${API_BASE}/api/corp/company`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (companyRes.ok) {
|
||||||
|
const data = await companyRes.json();
|
||||||
|
if (data) setCompany(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team members
|
||||||
|
const teamRes = await fetch(`${API_BASE}/api/corp/team/members`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (teamRes.ok) {
|
||||||
|
const data = await teamRes.json();
|
||||||
|
setTeamMembers(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load notification settings
|
||||||
|
const notifRes = await fetch(`${API_BASE}/api/user/notifications`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (notifRes.ok) {
|
||||||
|
const data = await notifRes.json();
|
||||||
|
if (data) setNotifications(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCompany = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/company`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(company),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
aethexToast({ message: "Company profile saved", type: "success" });
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to save");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aethexToast({ message: "Failed to save company profile", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNotifications = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/user/notifications`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(notifications),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
aethexToast({ message: "Notification preferences saved", type: "success" });
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to save");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aethexToast({ message: "Failed to save notifications", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInviteTeamMember = async () => {
|
||||||
|
if (!newMemberEmail) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/team/invite`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: newMemberEmail, role: "member" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
aethexToast({ message: "Invitation sent", type: "success" });
|
||||||
|
setNewMemberEmail("");
|
||||||
|
loadSettings();
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to invite");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aethexToast({ message: "Failed to send invitation", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTeamMember = async (memberId: string) => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) throw new Error("No auth token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/corp/team/members/${memberId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
aethexToast({ message: "Team member removed", type: "success" });
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aethexToast({ message: "Failed to remove member", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return <LoadingScreen message="Loading Settings..." />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="relative min-h-screen bg-black text-white overflow-hidden pb-12">
|
<div className="min-h-screen bg-gradient-to-b from-black via-slate-950/50 to-black py-8">
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-[0.12] [background-image:radial-gradient(circle_at_top,#3b82f6_0,rgba(0,0,0,0.45)_55%,rgba(0,0,0,0.9)_100%)]" />
|
<div className="container mx-auto px-4 max-w-5xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<main className="relative z-10">
|
<div className="space-y-4">
|
||||||
<section className="border-b border-slate-800 py-8">
|
<Button
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={() => navigate("/hub/client")}
|
||||||
size="sm"
|
className="text-slate-400 hover:text-white"
|
||||||
onClick={() => navigate("/hub/client")}
|
>
|
||||||
className="mb-4 text-slate-400"
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
>
|
Back to Portal
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Back to Portal
|
<div className="flex items-center gap-3">
|
||||||
</Button>
|
<Settings className="h-10 w-10 text-slate-400" />
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<Settings className="h-8 w-8 text-blue-400" />
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-200 to-slate-400 bg-clip-text text-transparent">
|
||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">Manage your account and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="py-12">
|
{/* Tabs */}
|
||||||
<div className="container mx-auto max-w-7xl px-4">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<Card className="bg-slate-800/30 border-slate-700">
|
<TabsList className="bg-slate-800/50 border border-slate-700">
|
||||||
<CardContent className="p-12 text-center">
|
<TabsTrigger value="company">
|
||||||
<Settings className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
<p className="text-slate-400 mb-6">
|
Company
|
||||||
Account settings and preferences coming soon
|
</TabsTrigger>
|
||||||
</p>
|
<TabsTrigger value="team">
|
||||||
<Button
|
<Users className="h-4 w-4 mr-2" />
|
||||||
variant="outline"
|
Team
|
||||||
onClick={() => navigate("/hub/client")}
|
</TabsTrigger>
|
||||||
>
|
<TabsTrigger value="notifications">
|
||||||
Back to Portal
|
<Bell className="h-4 w-4 mr-2" />
|
||||||
|
Notifications
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="billing">
|
||||||
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
|
Billing
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
Security
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Company Tab */}
|
||||||
|
<TabsContent value="company" className="space-y-6">
|
||||||
|
<Card className="bg-slate-900/50 border-slate-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Company Profile</CardTitle>
|
||||||
|
<CardDescription>Update your organization's information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Company Name</Label>
|
||||||
|
<Input
|
||||||
|
value={company.name}
|
||||||
|
onChange={(e) => setCompany({ ...company, name: e.target.value })}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Industry</Label>
|
||||||
|
<Input
|
||||||
|
value={company.industry}
|
||||||
|
onChange={(e) => setCompany({ ...company, industry: e.target.value })}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Website</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={company.website}
|
||||||
|
onChange={(e) => setCompany({ ...company, website: e.target.value })}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phone</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={company.phone}
|
||||||
|
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5 text-slate-400" />
|
||||||
|
Address
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<Label>Street Address</Label>
|
||||||
|
<Input
|
||||||
|
value={company.address.street}
|
||||||
|
onChange={(e) => setCompany({
|
||||||
|
...company,
|
||||||
|
address: { ...company.address, street: e.target.value }
|
||||||
|
})}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>City</Label>
|
||||||
|
<Input
|
||||||
|
value={company.address.city}
|
||||||
|
onChange={(e) => setCompany({
|
||||||
|
...company,
|
||||||
|
address: { ...company.address, city: e.target.value }
|
||||||
|
})}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>State/Province</Label>
|
||||||
|
<Input
|
||||||
|
value={company.address.state}
|
||||||
|
onChange={(e) => setCompany({
|
||||||
|
...company,
|
||||||
|
address: { ...company.address, state: e.target.value }
|
||||||
|
})}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ZIP/Postal Code</Label>
|
||||||
|
<Input
|
||||||
|
value={company.address.zip}
|
||||||
|
onChange={(e) => setCompany({
|
||||||
|
...company,
|
||||||
|
address: { ...company.address, zip: e.target.value }
|
||||||
|
})}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Country</Label>
|
||||||
|
<Input
|
||||||
|
value={company.address.country}
|
||||||
|
onChange={(e) => setCompany({
|
||||||
|
...company,
|
||||||
|
address: { ...company.address, country: e.target.value }
|
||||||
|
})}
|
||||||
|
className="bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveCompany} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</TabsContent>
|
||||||
</section>
|
|
||||||
</main>
|
{/* Team Tab */}
|
||||||
|
<TabsContent value="team" className="space-y-6">
|
||||||
|
<Card className="bg-slate-900/50 border-slate-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage who has access to your client portal</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Invite New Member */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Enter email to invite..."
|
||||||
|
value={newMemberEmail}
|
||||||
|
onChange={(e) => setNewMemberEmail(e.target.value)}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleInviteTeamMember}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{teamMembers.length === 0 ? (
|
||||||
|
<div className="p-8 text-center border border-dashed border-slate-700 rounded-lg">
|
||||||
|
<Users className="h-8 w-8 mx-auto text-gray-500 mb-2" />
|
||||||
|
<p className="text-gray-400">No team members yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
teamMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="p-4 bg-slate-800/50 rounded-lg border border-slate-700 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-slate-700 flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{member.name?.charAt(0) || member.email.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{member.name || member.email}</p>
|
||||||
|
<p className="text-sm text-gray-400">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className={
|
||||||
|
member.role === "admin"
|
||||||
|
? "bg-purple-500/20 text-purple-300"
|
||||||
|
: "bg-slate-500/20 text-slate-300"
|
||||||
|
}>
|
||||||
|
{member.role}
|
||||||
|
</Badge>
|
||||||
|
{!member.accepted && (
|
||||||
|
<Badge className="bg-yellow-500/20 text-yellow-300">Pending</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-400 hover:text-red-300"
|
||||||
|
onClick={() => handleRemoveTeamMember(member.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
<TabsContent value="notifications" className="space-y-6">
|
||||||
|
<Card className="bg-slate-900/50 border-slate-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notification Preferences</CardTitle>
|
||||||
|
<CardDescription>Choose what updates you want to receive</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Invoice Notifications</p>
|
||||||
|
<p className="text-sm text-gray-400">Receive emails when invoices are issued or paid</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={notifications.email_invoices}
|
||||||
|
onCheckedChange={(checked) => setNotifications({ ...notifications, email_invoices: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Milestone Updates</p>
|
||||||
|
<p className="text-sm text-gray-400">Get notified when project milestones are completed</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={notifications.email_milestones}
|
||||||
|
onCheckedChange={(checked) => setNotifications({ ...notifications, email_milestones: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Weekly Reports</p>
|
||||||
|
<p className="text-sm text-gray-400">Receive weekly project status reports</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={notifications.email_reports}
|
||||||
|
onCheckedChange={(checked) => setNotifications({ ...notifications, email_reports: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Team Updates</p>
|
||||||
|
<p className="text-sm text-gray-400">Notifications about team member changes</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={notifications.email_team_updates}
|
||||||
|
onCheckedChange={(checked) => setNotifications({ ...notifications, email_team_updates: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Urgent SMS Alerts</p>
|
||||||
|
<p className="text-sm text-gray-400">Receive SMS for critical updates</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={notifications.sms_urgent}
|
||||||
|
onCheckedChange={(checked) => setNotifications({ ...notifications, sms_urgent: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveNotifications} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{saving ? "Saving..." : "Save Preferences"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Billing Tab */}
|
||||||
|
<TabsContent value="billing" className="space-y-6">
|
||||||
|
<Card className="bg-slate-900/50 border-slate-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Billing Information</CardTitle>
|
||||||
|
<CardDescription>Manage payment methods and billing details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Billing Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={company.billing_email}
|
||||||
|
onChange={(e) => setCompany({ ...company, billing_email: e.target.value })}
|
||||||
|
className="pl-10 bg-slate-800/50 border-slate-700"
|
||||||
|
placeholder="billing@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold text-white">Payment Methods</h3>
|
||||||
|
<div className="p-4 bg-slate-800/50 rounded-lg border border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-6 w-6 text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">•••• •••• •••• 4242</p>
|
||||||
|
<p className="text-sm text-gray-400">Expires 12/26</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-500/20 text-green-300">Default</Badge>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full border-slate-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Payment Method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Security Tab */}
|
||||||
|
<TabsContent value="security" className="space-y-6">
|
||||||
|
<Card className="bg-slate-900/50 border-slate-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Settings</CardTitle>
|
||||||
|
<CardDescription>Manage your account security</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Key className="h-5 w-5 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Change Password</p>
|
||||||
|
<p className="text-sm text-gray-400">Update your account password</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-5 w-5 text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">Two-Factor Authentication</p>
|
||||||
|
<p className="text-sm text-gray-400">Add an extra layer of security</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-slate-700" />
|
||||||
|
|
||||||
|
<div className="p-4 bg-red-500/10 rounded-lg border border-red-500/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-300">Danger Zone</p>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
Permanently delete your account and all associated data
|
||||||
|
</p>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
357
docs/PORTAL-IMPLEMENTATION-PLAN.md
Normal file
357
docs/PORTAL-IMPLEMENTATION-PLAN.md
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
# Portal Implementation Plan
|
||||||
|
|
||||||
|
> **Scope:** Fix Client Portal, Build Staff Onboarding, Build Candidate Portal
|
||||||
|
> **Foundation:** Informational only (redirects to aethex.foundation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CLIENT PORTAL FIX (4 Pages)
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- `ClientHub.tsx` - ✅ Working (745 lines)
|
||||||
|
- `ClientDashboard.tsx` - ✅ Working (709 lines)
|
||||||
|
- `ClientProjects.tsx` - ✅ Working (317 lines)
|
||||||
|
- `ClientContracts.tsx` - ❌ 56-line stub
|
||||||
|
- `ClientInvoices.tsx` - ❌ 56-line stub
|
||||||
|
- `ClientReports.tsx` - ❌ 56-line stub
|
||||||
|
- `ClientSettings.tsx` - ❌ 56-line stub
|
||||||
|
|
||||||
|
### Build Out
|
||||||
|
|
||||||
|
#### ClientContracts.tsx
|
||||||
|
```
|
||||||
|
Features:
|
||||||
|
- Contract list with status (Draft, Active, Completed, Expired)
|
||||||
|
- Contract details view (scope, terms, milestones)
|
||||||
|
- Document preview/download (PDF)
|
||||||
|
- E-signature integration placeholder
|
||||||
|
- Amendment history
|
||||||
|
- Filter by status/date
|
||||||
|
|
||||||
|
API: /api/corp/contracts (already exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ClientInvoices.tsx
|
||||||
|
```
|
||||||
|
Features:
|
||||||
|
- Invoice list with status (Pending, Paid, Overdue)
|
||||||
|
- Invoice detail view (line items, tax, total)
|
||||||
|
- Payment history
|
||||||
|
- Download invoice PDF
|
||||||
|
- Pay now button (Stripe integration)
|
||||||
|
- Filter by status/date range
|
||||||
|
|
||||||
|
API: /api/corp/invoices (already exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ClientReports.tsx
|
||||||
|
```
|
||||||
|
Features:
|
||||||
|
- Project progress reports
|
||||||
|
- Time tracking summaries
|
||||||
|
- Budget vs actual spending
|
||||||
|
- Milestone completion rates
|
||||||
|
- Export to PDF/CSV
|
||||||
|
- Date range selector
|
||||||
|
|
||||||
|
API: /api/corp/analytics/summary (stub - needs build)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ClientSettings.tsx
|
||||||
|
```
|
||||||
|
Features:
|
||||||
|
- Company profile (name, logo, address)
|
||||||
|
- Team member access management
|
||||||
|
- Notification preferences
|
||||||
|
- Billing information
|
||||||
|
- API keys (if applicable)
|
||||||
|
- Account deletion
|
||||||
|
|
||||||
|
API: /api/user/profile-update (exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. STAFF ONBOARDING PORTAL (New)
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
```
|
||||||
|
client/pages/staff/
|
||||||
|
├── StaffOnboarding.tsx # Main onboarding hub
|
||||||
|
├── StaffOnboardingChecklist.tsx # Interactive checklist
|
||||||
|
├── StaffOnboardingProgress.tsx # Progress tracker
|
||||||
|
└── StaffOnboardingResources.tsx # Quick links & docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### StaffOnboarding.tsx - Main Hub
|
||||||
|
```
|
||||||
|
Sections:
|
||||||
|
1. Welcome Banner (personalized with name, start date, manager)
|
||||||
|
2. Progress Ring (% complete)
|
||||||
|
3. Current Phase (Day 1 / Week 1 / Month 1)
|
||||||
|
4. Quick Actions:
|
||||||
|
- Complete checklist items
|
||||||
|
- Meet your team
|
||||||
|
- Access resources
|
||||||
|
- Schedule 1-on-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### StaffOnboardingChecklist.tsx - Interactive Checklist
|
||||||
|
```
|
||||||
|
Day 1:
|
||||||
|
☐ Complete HR paperwork
|
||||||
|
☐ Set up workstation
|
||||||
|
☐ Join Discord server
|
||||||
|
☐ Meet your manager
|
||||||
|
☐ Review company handbook
|
||||||
|
|
||||||
|
Week 1:
|
||||||
|
☐ Complete security training
|
||||||
|
☐ Set up development environment
|
||||||
|
☐ Review codebase architecture
|
||||||
|
☐ Attend team standup
|
||||||
|
☐ Complete first small task
|
||||||
|
|
||||||
|
Month 1:
|
||||||
|
☐ Complete onboarding course
|
||||||
|
☐ Contribute to first sprint
|
||||||
|
☐ 30-day check-in with manager
|
||||||
|
☐ Set Q1 OKRs
|
||||||
|
☐ Shadow a senior dev
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Check items to mark complete
|
||||||
|
- Progress saves to database
|
||||||
|
- Manager can view progress
|
||||||
|
- Automatic reminders
|
||||||
|
- Achievement unlocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema (New)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE staff_onboarding_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
checklist_item TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL, -- 'day1', 'week1', 'month1'
|
||||||
|
completed BOOLEAN DEFAULT FALSE,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints (New)
|
||||||
|
```
|
||||||
|
GET /api/staff/onboarding # Get user's progress
|
||||||
|
POST /api/staff/onboarding/complete # Mark item complete
|
||||||
|
GET /api/staff/onboarding/admin # Manager view of team progress
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CANDIDATE PORTAL (New)
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
```
|
||||||
|
client/pages/candidate/
|
||||||
|
├── CandidatePortal.tsx # Main dashboard
|
||||||
|
├── CandidateProfile.tsx # Profile builder
|
||||||
|
├── CandidateApplications.tsx # Enhanced MyApplications
|
||||||
|
├── CandidateInterviews.tsx # Interview scheduler
|
||||||
|
└── CandidateOffers.tsx # Offer tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
### CandidatePortal.tsx - Dashboard
|
||||||
|
```
|
||||||
|
Sections:
|
||||||
|
1. Application Stats
|
||||||
|
- Total applications
|
||||||
|
- In review
|
||||||
|
- Interviews scheduled
|
||||||
|
- Offers received
|
||||||
|
|
||||||
|
2. Quick Actions
|
||||||
|
- Browse opportunities
|
||||||
|
- Update profile
|
||||||
|
- View applications
|
||||||
|
- Check messages
|
||||||
|
|
||||||
|
3. Recent Activity
|
||||||
|
- Application status changes
|
||||||
|
- Interview invites
|
||||||
|
- New opportunities matching skills
|
||||||
|
|
||||||
|
4. Recommended Jobs
|
||||||
|
- Based on skills/interests
|
||||||
|
```
|
||||||
|
|
||||||
|
### CandidateProfile.tsx - Profile Builder
|
||||||
|
```
|
||||||
|
Sections:
|
||||||
|
1. Basic Info (from user profile)
|
||||||
|
2. Resume/CV Upload
|
||||||
|
3. Portfolio Links (GitHub, Behance, etc.)
|
||||||
|
4. Skills & Expertise (tags)
|
||||||
|
5. Work History
|
||||||
|
6. Education
|
||||||
|
7. Availability & Rate (if freelancer)
|
||||||
|
8. Profile completeness meter
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Import from LinkedIn (future)
|
||||||
|
- Public profile URL
|
||||||
|
- Privacy settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### CandidateApplications.tsx - Enhanced
|
||||||
|
```
|
||||||
|
Improvements over MyApplications:
|
||||||
|
- Timeline view of application journey
|
||||||
|
- Communication thread with employer
|
||||||
|
- Document attachments
|
||||||
|
- Interview scheduling integration
|
||||||
|
- Offer acceptance workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
### CandidateInterviews.tsx
|
||||||
|
```
|
||||||
|
Features:
|
||||||
|
- Upcoming interviews list
|
||||||
|
- Calendar integration
|
||||||
|
- Video call links
|
||||||
|
- Interview prep resources
|
||||||
|
- Feedback after interview
|
||||||
|
- Reschedule option
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema (New)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE candidate_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES auth.users(id) UNIQUE,
|
||||||
|
resume_url TEXT,
|
||||||
|
portfolio_urls JSONB DEFAULT '[]',
|
||||||
|
work_history JSONB DEFAULT '[]',
|
||||||
|
education JSONB DEFAULT '[]',
|
||||||
|
skills TEXT[] DEFAULT '{}',
|
||||||
|
availability TEXT, -- 'immediate', '2_weeks', '1_month'
|
||||||
|
desired_rate DECIMAL(10,2),
|
||||||
|
profile_completeness INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE candidate_interviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
application_id UUID REFERENCES aethex_applications(id),
|
||||||
|
candidate_id UUID REFERENCES auth.users(id),
|
||||||
|
employer_id UUID REFERENCES auth.users(id),
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
duration_minutes INTEGER DEFAULT 30,
|
||||||
|
meeting_link TEXT,
|
||||||
|
status TEXT DEFAULT 'scheduled', -- 'scheduled', 'completed', 'cancelled', 'rescheduled'
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints (New)
|
||||||
|
```
|
||||||
|
GET /api/candidate/profile # Get candidate profile
|
||||||
|
POST /api/candidate/profile # Create/update profile
|
||||||
|
POST /api/candidate/resume # Upload resume
|
||||||
|
GET /api/candidate/interviews # Get scheduled interviews
|
||||||
|
POST /api/candidate/interviews # Schedule interview
|
||||||
|
GET /api/candidate/recommendations # Job recommendations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FOUNDATION - INFORMATIONAL ONLY
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- `Foundation.tsx` - Landing page
|
||||||
|
- `FoundationDashboard.tsx` - Placeholder dashboard
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
```
|
||||||
|
FoundationDashboard.tsx:
|
||||||
|
- Remove dashboard functionality
|
||||||
|
- Show informational content about Foundation programs
|
||||||
|
- Add prominent CTA: "Visit aethex.foundation for full experience"
|
||||||
|
- Redirect links to aethex.foundation
|
||||||
|
|
||||||
|
Or simply redirect /foundation/dashboard → aethex.foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IMPLEMENTATION ORDER
|
||||||
|
|
||||||
|
### Phase 1: Client Portal (Quick Wins)
|
||||||
|
1. `ClientContracts.tsx` - Build full contract management
|
||||||
|
2. `ClientInvoices.tsx` - Build full invoice management
|
||||||
|
3. `ClientReports.tsx` - Build reporting dashboard
|
||||||
|
4. `ClientSettings.tsx` - Build settings page
|
||||||
|
|
||||||
|
### Phase 2: Candidate Portal
|
||||||
|
1. Database migration for candidate_profiles, candidate_interviews
|
||||||
|
2. `CandidatePortal.tsx` - Main dashboard
|
||||||
|
3. `CandidateProfile.tsx` - Profile builder
|
||||||
|
4. `CandidateApplications.tsx` - Enhanced applications
|
||||||
|
5. `CandidateInterviews.tsx` - Interview management
|
||||||
|
6. API endpoints
|
||||||
|
|
||||||
|
### Phase 3: Staff Onboarding
|
||||||
|
1. Database migration for staff_onboarding_progress
|
||||||
|
2. `StaffOnboarding.tsx` - Main hub
|
||||||
|
3. `StaffOnboardingChecklist.tsx` - Interactive checklist
|
||||||
|
4. API endpoints
|
||||||
|
5. Manager admin view
|
||||||
|
|
||||||
|
### Phase 4: Foundation Cleanup
|
||||||
|
1. Update FoundationDashboard to informational
|
||||||
|
2. Add redirects to aethex.foundation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FILE CHANGES SUMMARY
|
||||||
|
|
||||||
|
### New Files (12)
|
||||||
|
```
|
||||||
|
client/pages/candidate/CandidatePortal.tsx
|
||||||
|
client/pages/candidate/CandidateProfile.tsx
|
||||||
|
client/pages/candidate/CandidateApplications.tsx
|
||||||
|
client/pages/candidate/CandidateInterviews.tsx
|
||||||
|
client/pages/candidate/CandidateOffers.tsx
|
||||||
|
client/pages/staff/StaffOnboarding.tsx
|
||||||
|
client/pages/staff/StaffOnboardingChecklist.tsx
|
||||||
|
api/candidate/profile.ts
|
||||||
|
api/candidate/interviews.ts
|
||||||
|
api/staff/onboarding.ts
|
||||||
|
supabase/migrations/YYYYMMDD_add_candidate_portal.sql
|
||||||
|
supabase/migrations/YYYYMMDD_add_staff_onboarding.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files (5)
|
||||||
|
```
|
||||||
|
client/pages/hub/ClientContracts.tsx (rebuild)
|
||||||
|
client/pages/hub/ClientInvoices.tsx (rebuild)
|
||||||
|
client/pages/hub/ClientReports.tsx (rebuild)
|
||||||
|
client/pages/hub/ClientSettings.tsx (rebuild)
|
||||||
|
client/pages/dashboards/FoundationDashboard.tsx (simplify)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESTIMATED EFFORT
|
||||||
|
|
||||||
|
| Component | Files | Complexity |
|
||||||
|
|-----------|-------|------------|
|
||||||
|
| Client Portal Fix | 4 | Medium |
|
||||||
|
| Candidate Portal | 6 | High |
|
||||||
|
| Staff Onboarding | 4 | Medium |
|
||||||
|
| Foundation Cleanup | 1 | Low |
|
||||||
|
| **Total** | **15** | |
|
||||||
|
|
||||||
|
Ready to implement?
|
||||||
Loading…
Reference in a new issue