Complete Candidate Portal with all pages and routes
- Add CandidateInterviews.tsx with interview list, filtering by status, meeting type badges, and join meeting buttons - Add CandidateOffers.tsx with offer management, accept/decline dialogs, expiry warnings, and salary formatting - Add routes to App.tsx for /candidate, /candidate/profile, /candidate/interviews, /candidate/offers
This commit is contained in:
parent
0674a282b0
commit
1a2a9af335
3 changed files with 1031 additions and 0 deletions
|
|
@ -161,6 +161,10 @@ import StaffProjectTracking from "./pages/staff/StaffProjectTracking";
|
|||
import StaffTeamHandbook from "./pages/staff/StaffTeamHandbook";
|
||||
import StaffOnboarding from "./pages/staff/StaffOnboarding";
|
||||
import StaffOnboardingChecklist from "./pages/staff/StaffOnboardingChecklist";
|
||||
import CandidatePortal from "./pages/candidate/CandidatePortal";
|
||||
import CandidateProfile from "./pages/candidate/CandidateProfile";
|
||||
import CandidateInterviews from "./pages/candidate/CandidateInterviews";
|
||||
import CandidateOffers from "./pages/candidate/CandidateOffers";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -542,6 +546,40 @@ const App = () => (
|
|||
}
|
||||
/>
|
||||
|
||||
{/* Candidate Portal Routes */}
|
||||
<Route
|
||||
path="/candidate"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<CandidatePortal />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidate/profile"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<CandidateProfile />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidate/interviews"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<CandidateInterviews />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidate/offers"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<CandidateOffers />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dev-Link routes - now redirect to Nexus Opportunities with ecosystem filter */}
|
||||
<Route path="/dev-link" element={<Navigate to="/opportunities?ecosystem=roblox" replace />} />
|
||||
<Route
|
||||
|
|
|
|||
402
client/pages/candidate/CandidateInterviews.tsx
Normal file
402
client/pages/candidate/CandidateInterviews.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Link } from "wouter";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Calendar,
|
||||
Video,
|
||||
Phone,
|
||||
MapPin,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
interface Interview {
|
||||
id: string;
|
||||
scheduled_at: string;
|
||||
duration_minutes: number;
|
||||
meeting_link: string | null;
|
||||
meeting_type: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
candidate_feedback: string | null;
|
||||
employer: {
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface GroupedInterviews {
|
||||
upcoming: Interview[];
|
||||
past: Interview[];
|
||||
cancelled: Interview[];
|
||||
}
|
||||
|
||||
export default function CandidateInterviews() {
|
||||
const { session } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [interviews, setInterviews] = useState<Interview[]>([]);
|
||||
const [grouped, setGrouped] = useState<GroupedInterviews>({
|
||||
upcoming: [],
|
||||
past: [],
|
||||
cancelled: [],
|
||||
});
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchInterviews();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchInterviews = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/candidate/interviews", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInterviews(data.interviews || []);
|
||||
setGrouped(data.grouped || { upcoming: [], past: [], cancelled: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching interviews:", error);
|
||||
aethexToast.error("Failed to load interviews");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
return new Date(date).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const getMeetingIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "video":
|
||||
return <Video className="h-4 w-4" />;
|
||||
case "phone":
|
||||
return <Phone className="h-4 w-4" />;
|
||||
case "in_person":
|
||||
return <MapPin className="h-4 w-4" />;
|
||||
default:
|
||||
return <Video className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30">
|
||||
Scheduled
|
||||
</Badge>
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
||||
Completed
|
||||
</Badge>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||
Cancelled
|
||||
</Badge>
|
||||
);
|
||||
case "rescheduled":
|
||||
return (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
|
||||
Rescheduled
|
||||
</Badge>
|
||||
);
|
||||
case "no_show":
|
||||
return (
|
||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30">
|
||||
No Show
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getFilteredInterviews = () => {
|
||||
switch (filter) {
|
||||
case "upcoming":
|
||||
return grouped.upcoming;
|
||||
case "past":
|
||||
return grouped.past;
|
||||
case "cancelled":
|
||||
return grouped.cancelled;
|
||||
default:
|
||||
return interviews;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Interviews" description="Manage your interview schedule" />
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredInterviews = getFilteredInterviews();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Interviews" description="Manage your interview schedule" />
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/candidate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
||||
<Calendar className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-violet-100">
|
||||
Interviews
|
||||
</h1>
|
||||
<p className="text-violet-200/70">
|
||||
Manage your interview schedule
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{grouped.upcoming.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Upcoming</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{grouped.past.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-slate-400">
|
||||
{interviews.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-48 bg-slate-800/50 border-slate-700 text-slate-100">
|
||||
<SelectValue placeholder="Filter interviews" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Interviews</SelectItem>
|
||||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="past">Past</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Interviews List */}
|
||||
<div className="space-y-4">
|
||||
{filteredInterviews.length === 0 ? (
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-12 pb-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
No interviews found
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{filter === "all"
|
||||
? "You don't have any scheduled interviews yet"
|
||||
: `No ${filter} interviews`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredInterviews.map((interview) => (
|
||||
<Card
|
||||
key={interview.id}
|
||||
className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/30 transition-all"
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage
|
||||
src={interview.employer?.avatar_url || ""}
|
||||
/>
|
||||
<AvatarFallback className="bg-violet-500/20 text-violet-300">
|
||||
{interview.employer?.full_name
|
||||
? getInitials(interview.employer.full_name)
|
||||
: "E"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-violet-100">
|
||||
Interview with{" "}
|
||||
{interview.employer?.full_name || "Employer"}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-400 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDate(interview.scheduled_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatTime(interview.scheduled_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{getMeetingIcon(interview.meeting_type)}
|
||||
{interview.duration_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(interview.status)}
|
||||
{interview.meeting_link &&
|
||||
interview.status === "scheduled" && (
|
||||
<a
|
||||
href={interview.meeting_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
Join Meeting
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{interview.notes && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="font-medium text-slate-300">
|
||||
Notes:
|
||||
</span>{" "}
|
||||
{interview.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-slate-800/30 border-slate-700/30">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium text-violet-100 mb-3">
|
||||
Interview Tips
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-slate-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
|
||||
Test your camera and microphone before video calls
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
|
||||
Join 5 minutes early to ensure everything works
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
|
||||
Have your resume and portfolio ready to share
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5" />
|
||||
Prepare questions to ask the interviewer
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
591
client/pages/candidate/CandidateOffers.tsx
Normal file
591
client/pages/candidate/CandidateOffers.tsx
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Link } from "wouter";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Gift,
|
||||
ArrowLeft,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Building,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Offer {
|
||||
id: string;
|
||||
position_title: string;
|
||||
company_name: string;
|
||||
salary_amount: number | null;
|
||||
salary_type: string | null;
|
||||
start_date: string | null;
|
||||
offer_expiry: string | null;
|
||||
benefits: any[];
|
||||
offer_letter_url: string | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
employer: {
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface GroupedOffers {
|
||||
pending: Offer[];
|
||||
accepted: Offer[];
|
||||
declined: Offer[];
|
||||
expired: Offer[];
|
||||
withdrawn: Offer[];
|
||||
}
|
||||
|
||||
export default function CandidateOffers() {
|
||||
const { session } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offers, setOffers] = useState<Offer[]>([]);
|
||||
const [grouped, setGrouped] = useState<GroupedOffers>({
|
||||
pending: [],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
withdrawn: [],
|
||||
});
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [respondingTo, setRespondingTo] = useState<Offer | null>(null);
|
||||
const [responseAction, setResponseAction] = useState<"accept" | "decline" | null>(null);
|
||||
const [responseNotes, setResponseNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchOffers();
|
||||
}
|
||||
}, [session?.access_token]);
|
||||
|
||||
const fetchOffers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/candidate/offers", {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOffers(data.offers || []);
|
||||
setGrouped(
|
||||
data.grouped || {
|
||||
pending: [],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
withdrawn: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching offers:", error);
|
||||
aethexToast.error("Failed to load offers");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const respondToOffer = async () => {
|
||||
if (!respondingTo || !responseAction || !session?.access_token) return;
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/candidate/offers", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: respondingTo.id,
|
||||
status: responseAction === "accept" ? "accepted" : "declined",
|
||||
notes: responseNotes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to respond to offer");
|
||||
|
||||
aethexToast.success(
|
||||
responseAction === "accept"
|
||||
? "Congratulations! You've accepted the offer."
|
||||
: "Offer declined",
|
||||
);
|
||||
|
||||
// Refresh offers
|
||||
await fetchOffers();
|
||||
|
||||
// Close dialog
|
||||
setRespondingTo(null);
|
||||
setResponseAction(null);
|
||||
setResponseNotes("");
|
||||
} catch (error) {
|
||||
console.error("Error responding to offer:", error);
|
||||
aethexToast.error("Failed to respond to offer");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatSalary = (amount: number | null, type: string | null) => {
|
||||
if (!amount) return "Not specified";
|
||||
const formatted = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
const suffix =
|
||||
type === "hourly" ? "/hr" : type === "monthly" ? "/mo" : "/yr";
|
||||
return formatted + suffix;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
case "accepted":
|
||||
return (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Accepted
|
||||
</Badge>
|
||||
);
|
||||
case "declined":
|
||||
return (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Declined
|
||||
</Badge>
|
||||
);
|
||||
case "expired":
|
||||
return (
|
||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30">
|
||||
Expired
|
||||
</Badge>
|
||||
);
|
||||
case "withdrawn":
|
||||
return (
|
||||
<Badge className="bg-slate-500/20 text-slate-300 border-slate-500/30">
|
||||
Withdrawn
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getDaysUntilExpiry = (expiry: string | null) => {
|
||||
if (!expiry) return null;
|
||||
const diff = new Date(expiry).getTime() - new Date().getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
return days;
|
||||
};
|
||||
|
||||
const getFilteredOffers = () => {
|
||||
switch (filter) {
|
||||
case "pending":
|
||||
return grouped.pending;
|
||||
case "accepted":
|
||||
return grouped.accepted;
|
||||
case "declined":
|
||||
return grouped.declined;
|
||||
case "expired":
|
||||
return grouped.expired;
|
||||
default:
|
||||
return offers;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Job Offers" description="Review and respond to job offers" />
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredOffers = getFilteredOffers();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Job Offers" description="Review and respond to job offers" />
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-green-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/candidate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 rounded-lg bg-green-500/20 border border-green-500/30">
|
||||
<Gift className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-violet-100">
|
||||
Job Offers
|
||||
</h1>
|
||||
<p className="text-violet-200/70">
|
||||
Review and respond to offers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{grouped.pending.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{grouped.accepted.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Accepted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{grouped.declined.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Declined</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-slate-400">
|
||||
{offers.length}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-48 bg-slate-800/50 border-slate-700 text-slate-100">
|
||||
<SelectValue placeholder="Filter offers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Offers</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="accepted">Accepted</SelectItem>
|
||||
<SelectItem value="declined">Declined</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Offers List */}
|
||||
<div className="space-y-4">
|
||||
{filteredOffers.length === 0 ? (
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-12 pb-12 text-center">
|
||||
<Gift className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
No offers found
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{filter === "all"
|
||||
? "You don't have any job offers yet"
|
||||
: `No ${filter} offers`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredOffers.map((offer) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(offer.offer_expiry);
|
||||
const isExpiringSoon =
|
||||
daysUntilExpiry !== null &&
|
||||
daysUntilExpiry > 0 &&
|
||||
daysUntilExpiry <= 3;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={offer.id}
|
||||
className={`bg-slate-800/50 border-slate-700/50 hover:border-violet-500/30 transition-all ${
|
||||
offer.status === "pending" && isExpiringSoon
|
||||
? "border-yellow-500/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-violet-100">
|
||||
{offer.position_title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
||||
<Building className="h-4 w-4" />
|
||||
{offer.company_name}
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(offer.status)}
|
||||
</div>
|
||||
|
||||
{/* Expiry Warning */}
|
||||
{offer.status === "pending" && isExpiringSoon && (
|
||||
<div className="flex items-center gap-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-yellow-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
Expires in {daysUntilExpiry} day
|
||||
{daysUntilExpiry !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<DollarSign className="h-4 w-4 text-green-400" />
|
||||
<span>
|
||||
{formatSalary(
|
||||
offer.salary_amount,
|
||||
offer.salary_type,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{offer.start_date && (
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<Calendar className="h-4 w-4 text-blue-400" />
|
||||
<span>Start: {formatDate(offer.start_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
{offer.offer_expiry && (
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<Clock className="h-4 w-4 text-yellow-400" />
|
||||
<span>
|
||||
Expires: {formatDate(offer.offer_expiry)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
{offer.benefits && offer.benefits.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.benefits.map((benefit: string, i: number) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-slate-300 border-slate-600"
|
||||
>
|
||||
{benefit}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-slate-700/50">
|
||||
{offer.status === "pending" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRespondingTo(offer);
|
||||
setResponseAction("accept");
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Accept Offer
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRespondingTo(offer);
|
||||
setResponseAction("decline");
|
||||
}}
|
||||
variant="outline"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{offer.offer_letter_url && (
|
||||
<a
|
||||
href={offer.offer_letter_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-violet-500/30 text-violet-300"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Offer Letter
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Dialog */}
|
||||
<Dialog
|
||||
open={!!respondingTo}
|
||||
onOpenChange={() => {
|
||||
setRespondingTo(null);
|
||||
setResponseAction(null);
|
||||
setResponseNotes("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-violet-100">
|
||||
{responseAction === "accept" ? "Accept Offer" : "Decline Offer"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
{responseAction === "accept"
|
||||
? "Congratulations! Please confirm you want to accept this offer."
|
||||
: "Are you sure you want to decline this offer? This action cannot be undone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{respondingTo && (
|
||||
<div className="py-4">
|
||||
<div className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 mb-4">
|
||||
<p className="font-medium text-violet-100">
|
||||
{respondingTo.position_title}
|
||||
</p>
|
||||
<p className="text-slate-400">{respondingTo.company_name}</p>
|
||||
<p className="text-green-400 mt-2">
|
||||
{formatSalary(
|
||||
respondingTo.salary_amount,
|
||||
respondingTo.salary_type,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-violet-200">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={responseNotes}
|
||||
onChange={(e) => setResponseNotes(e.target.value)}
|
||||
placeholder={
|
||||
responseAction === "accept"
|
||||
? "Thank you for this opportunity..."
|
||||
: "Reason for declining (optional)..."
|
||||
}
|
||||
className="bg-slate-700/50 border-slate-600 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRespondingTo(null);
|
||||
setResponseAction(null);
|
||||
setResponseNotes("");
|
||||
}}
|
||||
className="border-slate-600 text-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={respondToOffer}
|
||||
disabled={submitting}
|
||||
className={
|
||||
responseAction === "accept"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : responseAction === "accept" ? (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{responseAction === "accept" ? "Accept Offer" : "Decline Offer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue