repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Pan-African Youth Internship,…
stars
latest
clone command
git clone gitlawb://did:key:z6MkmNFz...XP9B/pan-african-you...git clone gitlawb://did:key:z6MkmNFz.../pan-african-you...53f59357sync from playground10m ago| #1 | import { useState, useEffect, type ChangeEvent, type FormEvent } from "react"; |
| #2 | import { supabase } from "./supabaseClient"; |
| #3 | |
| #4 | // ---- Constants ---- |
| #5 | |
| #6 | const ORG_TYPE_OPTIONS = [ |
| #7 | "Social Enterprise", "NGO / Non-Profit", "Company / Corporation", |
| #8 | "Government Agency", "International Organisation", "Academic Institution", |
| #9 | "Start-up", "Community Based Organisation", |
| #10 | ]; |
| #11 | |
| #12 | const ORG_SIZE_OPTIONS = [ |
| #13 | "1-10 employees", "11-50 employees", "51-200 employees", |
| #14 | "201-500 employees", "501-1000 employees", "1000+ employees", |
| #15 | ]; |
| #16 | |
| #17 | const OPPORTUNITY_TYPES = [ |
| #18 | "Internship", "Volunteering", "Graduate Trainee", "Field Placement", |
| #19 | "Research Assistantship", "Fellowship", "Apprenticeship", |
| #20 | ]; |
| #21 | |
| #22 | const COUNTRY_OPTIONS = [ |
| #23 | "Algeria", "Angola", "Benin", "Botswana", "Burkina Faso", "Burundi", |
| #24 | "Cameroon", "Cape Verde", "Central African Republic", "Chad", "Comoros", |
| #25 | "Democratic Republic of the Congo", "Republic of the Congo", "Djibouti", |
| #26 | "Egypt", "Equatorial Guinea", "Eritrea", "Ethiopia", "Gabon", "Gambia", |
| #27 | "Ghana", "Guinea", "Guinea-Bissau", "Ivory Coast", "Kenya", "Lesotho", |
| #28 | "Liberia", "Libya", "Madagascar", "Malawi", "Mali", "Mauritania", |
| #29 | "Mauritius", "Morocco", "Mozambique", "Namibia", "Niger", "Nigeria", |
| #30 | "Rwanda", "Sao Tome and Principe", "Senegal", "Seychelles", |
| #31 | "Sierra Leone", "Somalia", "South Africa", "South Sudan", "Sudan", |
| #32 | "Eswatini", "Tanzania", "Togo", "Tunisia", "Uganda", "Zambia", "Zimbabwe", |
| #33 | "Other", |
| #34 | ]; |
| #35 | |
| #36 | const SKILLS_OPTIONS = [ |
| #37 | "Graphic Design", "Data Analysis", "Social Media Management", "Accounting", |
| #38 | "Research", "Programming", "Writing", "Translation", "Marketing", |
| #39 | "Community Mobilisation", "Project Management", "Public Speaking", |
| #40 | "Photography", "Video Editing", "Customer Service", "Sales", |
| #41 | ]; |
| #42 | |
| #43 | const LANGUAGE_OPTIONS = [ |
| #44 | "English", "French", "Arabic", "Portuguese", "Swahili", "Amharic", |
| #45 | "Hausa", "Yoruba", "Zulu", "Any", |
| #46 | ]; |
| #47 | |
| #48 | const SECTOR_OPTIONS = [ |
| #49 | "Agriculture", "Technology", "Health", "Education", "Climate & Environment", |
| #50 | "Finance", "Media & Communications", "Engineering", "Development", |
| #51 | "Humanitarian Work", "Law & Justice", "Energy", |
| #52 | ]; |
| #53 | |
| #54 | // ---- Types ---- |
| #55 | |
| #56 | interface OpportunityForm { |
| #57 | title: string; |
| #58 | type: string; |
| #59 | department: string; |
| #60 | country: string; |
| #61 | city: string; |
| #62 | locationType: string; |
| #63 | paidStatus: string; |
| #64 | stipendAmount: string; |
| #65 | requiredCourse: string; |
| #66 | skillsNeeded: string[]; |
| #67 | experienceLevel: string; |
| #68 | languageReq: string; |
| #69 | startDate: string; |
| #70 | duration: string; |
| #71 | deadline: string; |
| #72 | responsibilities: string; |
| #73 | learningOutcomes: string; |
| #74 | expectations: string; |
| #75 | benefits: string; |
| #76 | slotsAvailable: string; |
| #77 | applyMethod: string; |
| #78 | externalLink: string; |
| #79 | } |
| #80 | |
| #81 | const EMPTY_OPP_FORM: OpportunityForm = { |
| #82 | title: "", |
| #83 | type: "", |
| #84 | department: "", |
| #85 | country: "", |
| #86 | city: "", |
| #87 | locationType: "On-site", |
| #88 | paidStatus: "Unpaid", |
| #89 | stipendAmount: "", |
| #90 | requiredCourse: "", |
| #91 | skillsNeeded: [], |
| #92 | experienceLevel: "No experience required", |
| #93 | languageReq: "English", |
| #94 | startDate: "", |
| #95 | duration: "", |
| #96 | deadline: "", |
| #97 | responsibilities: "", |
| #98 | learningOutcomes: "", |
| #99 | expectations: "", |
| #100 | benefits: "", |
| #101 | slotsAvailable: "", |
| #102 | applyMethod: "direct", |
| #103 | externalLink: "", |
| #104 | }; |
| #105 | |
| #106 | interface Applicant { |
| #107 | id: string; |
| #108 | name: string; |
| #109 | email?: string; |
| #110 | university: string; |
| #111 | course: string; |
| #112 | graduationYear: string; |
| #113 | appliedFor: string; |
| #114 | opportunityId?: string; |
| #115 | date: string; |
| #116 | status: "New" | "Reviewed" | "Shortlisted" | "Selected" | "Rejected"; |
| #117 | cvUrl?: string; |
| #118 | coverLetterUrl?: string; |
| #119 | transcriptUrl?: string; |
| #120 | userId?: string; |
| #121 | } |
| #122 | |
| #123 | const EMPTY_APPLICANTS: Applicant[] = []; |
| #124 | |
| #125 | interface PostedOpportunity { |
| #126 | id: string | number; |
| #127 | title: string; |
| #128 | type: string; |
| #129 | location: string; |
| #130 | posted: string; |
| #131 | deadline: string; |
| #132 | applicants: number; |
| #133 | slots: number; |
| #134 | paidStatus?: string; |
| #135 | stipendAmount?: string; |
| #136 | responsibilities?: string; |
| #137 | learningOutcomes?: string; |
| #138 | expectations?: string; |
| #139 | benefits?: string; |
| #140 | externalLink?: string; |
| #141 | description?: string; |
| #142 | duration?: string; |
| #143 | skills?: string[]; |
| #144 | status: "Active" | "Closed" | "Draft"; |
| #145 | } |
| #146 | |
| #147 | const EMPTY_OPPORTUNITIES: PostedOpportunity[] = []; |
| #148 | |
| #149 | // ---- Opportunity Posting Form ---- |
| #150 | |
| #151 | function OpportunityPostingForm({ onClose, orgName, userId, onPosted }: { onClose: () => void; orgName?: string; userId?: string | null; onPosted?: () => void }) { |
| #152 | const [form, setForm] = useState<OpportunityForm>({ ...EMPTY_OPP_FORM }); |
| #153 | const [submitted, setSubmitted] = useState(false); |
| #154 | const [submitting, setSubmitting] = useState(false); |
| #155 | |
| #156 | const setField = <K extends keyof OpportunityForm>(key: K, val: OpportunityForm[K]) => { |
| #157 | setForm((prev) => ({ ...prev, [key]: val })); |
| #158 | }; |
| #159 | |
| #160 | const toggleSkill = (skill: string) => { |
| #161 | setForm((prev) => ({ |
| #162 | ...prev, |
| #163 | skillsNeeded: prev.skillsNeeded.includes(skill) |
| #164 | ? prev.skillsNeeded.filter((s) => s !== skill) |
| #165 | : [...prev.skillsNeeded, skill], |
| #166 | })); |
| #167 | }; |
| #168 | |
| #169 | const handleSubmit = async (e: FormEvent) => { |
| #170 | e.preventDefault(); |
| #171 | setSubmitting(true); |
| #172 | |
| #173 | if (userId) { |
| #174 | const location = [form.city, form.country].filter(Boolean).join(", ") || "Remote"; |
| #175 | const { error } = await supabase.from("opportunities").insert({ |
| #176 | org_user_id: userId, |
| #177 | org_name: orgName || "", |
| #178 | title: form.title, |
| #179 | type: form.type, |
| #180 | location: location, |
| #181 | location_type: form.locationType, |
| #182 | paid_status: form.paidStatus, |
| #183 | stipend_amount: form.stipendAmount || null, |
| #184 | duration: form.duration, |
| #185 | deadline: form.deadline || null, |
| #186 | slots_available: form.slotsAvailable ? Number(form.slotsAvailable) : null, |
| #187 | responsibilities: form.responsibilities, |
| #188 | learning_outcomes: form.learningOutcomes, |
| #189 | expectations: form.expectations, |
| #190 | benefits: form.benefits, |
| #191 | description: form.responsibilities, |
| #192 | skills: form.skillsNeeded, |
| #193 | status: "Active", |
| #194 | }); |
| #195 | |
| #196 | if (error) { |
| #197 | console.error("Error posting opportunity:", error); |
| #198 | setSubmitting(false); |
| #199 | return; |
| #200 | } |
| #201 | } |
| #202 | |
| #203 | setSubmitting(false); |
| #204 | setSubmitted(true); |
| #205 | onPosted?.(); |
| #206 | }; |
| #207 | |
| #208 | if (submitted) { |
| #209 | return ( |
| #210 | <div className="dash-modal-overlay" onClick={onClose}> |
| #211 | <div className="dash-modal" onClick={(e) => e.stopPropagation()}> |
| #212 | <div className="reg-success" style={{ padding: "2rem 0", minHeight: "auto" }}> |
| #213 | <div className="reg-success-card" style={{ boxShadow: "none", border: "none", padding: "1.5rem" }}> |
| #214 | <div className="reg-success-icon">✅</div> |
| #215 | <h2>Opportunity Posted!</h2> |
| #216 | <p>"{form.title}" has been published and is now visible to youth users.{form.slotsAvailable && ` ${form.slotsAvailable} slot${Number(form.slotsAvailable) !== 1 ? "s" : ""} available.`}</p> |
| #217 | <button type="button" className="btn btn-primary" onClick={onClose} style={{ marginTop: "1rem" }}> |
| #218 | Back to Dashboard |
| #219 | </button> |
| #220 | </div> |
| #221 | </div> |
| #222 | </div> |
| #223 | </div> |
| #224 | ); |
| #225 | } |
| #226 | |
| #227 | return ( |
| #228 | <div className="dash-modal-overlay" onClick={onClose}> |
| #229 | <div className="dash-modal dash-modal-lg" onClick={(e) => e.stopPropagation()}> |
| #230 | <div className="dash-modal-header"> |
| #231 | <h2>Post New Opportunity</h2> |
| #232 | <button type="button" className="dash-modal-close" onClick={onClose}>✕</button> |
| #233 | </div> |
| #234 | <form onSubmit={handleSubmit} className="dash-modal-body"> |
| #235 | {/* Basic Details */} |
| #236 | <h3 className="reg-sub-heading" style={{ marginTop: 0 }}>Basic Details</h3> |
| #237 | <FormField label="Opportunity Title" required> |
| #238 | <TextInput value={form.title} onChange={(v) => setField("title", v)} placeholder="e.g. Software Engineering Intern" /> |
| #239 | </FormField> |
| #240 | <div className="reg-grid-2"> |
| #241 | <FormField label="Opportunity Type" required> |
| #242 | <select className="reg-input" value={form.type} onChange={(e) => setField("type", e.target.value)}> |
| #243 | <option value="">Select type...</option> |
| #244 | {OPPORTUNITY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} |
| #245 | </select> |
| #246 | </FormField> |
| #247 | <FormField label="Department / Team"> |
| #248 | <TextInput value={form.department} onChange={(v) => setField("department", v)} placeholder="e.g. Engineering" /> |
| #249 | </FormField> |
| #250 | <FormField label="Country" required> |
| #251 | <select className="reg-input" value={form.country} onChange={(e) => setField("country", e.target.value)}> |
| #252 | <option value="">Select country...</option> |
| #253 | {COUNTRY_OPTIONS.map((c) => <option key={c} value={c}>{c}</option>)} |
| #254 | </select> |
| #255 | </FormField> |
| #256 | <FormField label="City / Location" required> |
| #257 | <TextInput value={form.city} onChange={(v) => setField("city", v)} placeholder="e.g. Nairobi" /> |
| #258 | </FormField> |
| #259 | <FormField label="Location Type" required> |
| #260 | <select className="reg-input" value={form.locationType} onChange={(e) => setField("locationType", e.target.value)}> |
| #261 | <option>On-site</option> |
| #262 | <option>Remote</option> |
| #263 | <option>Hybrid</option> |
| #264 | </select> |
| #265 | </FormField> |
| #266 | <FormField label="Paid / Unpaid" required> |
| #267 | <select className="reg-input" value={form.paidStatus} onChange={(e) => setField("paidStatus", e.target.value)}> |
| #268 | <option>Unpaid</option> |
| #269 | <option>Paid</option> |
| #270 | <option>Stipend</option> |
| #271 | </select> |
| #272 | </FormField> |
| #273 | <FormField label="Number of Slots Available" hint="How many positions are open?"> |
| #274 | <TextInput value={form.slotsAvailable} onChange={(v) => setField("slotsAvailable", v)} type="number" placeholder="e.g. 5" /> |
| #275 | </FormField> |
| #276 | </div> |
| #277 | {(form.paidStatus === "Paid" || form.paidStatus === "Stipend") && ( |
| #278 | <FormField label="Stipend / Salary Amount" hint="Optional. e.g. $500/month"> |
| #279 | <TextInput value={form.stipendAmount} onChange={(v) => setField("stipendAmount", v)} placeholder="e.g. $500/month" /> |
| #280 | </FormField> |
| #281 | )} |
| #282 | |
| #283 | {/* Requirements */} |
| #284 | <h3 className="reg-sub-heading">Requirements</h3> |
| #285 | <FormField label="Required Course / Field of Study"> |
| #286 | <TextInput value={form.requiredCourse} onChange={(v) => setField("requiredCourse", v)} placeholder="e.g. Computer Science, Engineering" /> |
| #287 | </FormField> |
| #288 | <FormField label="Skills Needed"> |
| #289 | <div className="reg-chips"> |
| #290 | {SKILLS_OPTIONS.map((skill) => ( |
| #291 | <button |
| #292 | key={skill} |
| #293 | type="button" |
| #294 | className={`reg-chip ${form.skillsNeeded.includes(skill) ? "reg-chip-active" : ""}`} |
| #295 | onClick={() => toggleSkill(skill)} |
| #296 | > |
| #297 | {form.skillsNeeded.includes(skill) && <span className="reg-chip-check">✓</span>} |
| #298 | {skill} |
| #299 | </button> |
| #300 | ))} |
| #301 | </div> |
| #302 | </FormField> |
| #303 | <div className="reg-grid-2"> |
| #304 | <FormField label="Experience Level"> |
| #305 | <select className="reg-input" value={form.experienceLevel} onChange={(e) => setField("experienceLevel", e.target.value)}> |
| #306 | <option>No experience required</option> |
| #307 | <option>Some experience preferred</option> |
| #308 | <option>1-2 years</option> |
| #309 | <option>2+ years</option> |
| #310 | </select> |
| #311 | </FormField> |
| #312 | <FormField label="Language Requirements"> |
| #313 | <select className="reg-input" value={form.languageReq} onChange={(e) => setField("languageReq", e.target.value)}> |
| #314 | {LANGUAGE_OPTIONS.map((l) => <option key={l} value={l}>{l}</option>)} |
| #315 | </select> |
| #316 | </FormField> |
| #317 | </div> |
| #318 | |
| #319 | {/* Timing */} |
| #320 | <h3 className="reg-sub-heading">Timing</h3> |
| #321 | <div className="reg-grid-2"> |
| #322 | <FormField label="Start Date" required> |
| #323 | <TextInput value={form.startDate} onChange={(v) => setField("startDate", v)} type="date" /> |
| #324 | </FormField> |
| #325 | <FormField label="Duration" required> |
| #326 | <select className="reg-input" value={form.duration} onChange={(e) => setField("duration", e.target.value)}> |
| #327 | <option value="">Select duration...</option> |
| #328 | <option>1 month</option> |
| #329 | <option>2 months</option> |
| #330 | <option>3 months</option> |
| #331 | <option>6 months</option> |
| #332 | <option>12 months</option> |
| #333 | <option>Flexible</option> |
| #334 | </select> |
| #335 | </FormField> |
| #336 | </div> |
| #337 | <FormField label="Application Deadline" required> |
| #338 | <TextInput value={form.deadline} onChange={(v) => setField("deadline", v)} type="date" /> |
| #339 | </FormField> |
| #340 | |
| #341 | {/* Description */} |
| #342 | <h3 className="reg-sub-heading">Description</h3> |
| #343 | <FormField label="Responsibilities" required> |
| #344 | <textarea className="reg-input reg-textarea" value={form.responsibilities} onChange={(e) => setField("responsibilities", e.target.value)} placeholder="What will the intern/volunteer be doing?" rows={4} /> |
| #345 | </FormField> |
| #346 | <FormField label="Learning Outcomes"> |
| #347 | <textarea className="reg-input reg-textarea" value={form.learningOutcomes} onChange={(e) => setField("learningOutcomes", e.target.value)} placeholder="What will they learn?" rows={3} /> |
| #348 | </FormField> |
| #349 | <FormField label="Expectations"> |
| #350 | <textarea className="reg-input reg-textarea" value={form.expectations} onChange={(e) => setField("expectations", e.target.value)} placeholder="What do you expect from applicants?" rows={3} /> |
| #351 | </FormField> |
| #352 | <FormField label="Benefits"> |
| #353 | <textarea className="reg-input reg-textarea" value={form.benefits} onChange={(e) => setField("benefits", e.target.value)} placeholder="e.g. Mentorship, certificate, networking..." rows={3} /> |
| #354 | </FormField> |
| #355 | |
| #356 | {/* Application Process */} |
| #357 | <h3 className="reg-sub-heading">Application Process</h3> |
| #358 | <FormField label="How should applicants apply?" required> |
| #359 | <select className="reg-input" value={form.applyMethod} onChange={(e) => setField("applyMethod", e.target.value)}> |
| #360 | <option value="direct">Apply directly on FursaLink</option> |
| #361 | <option value="external">External application link</option> |
| #362 | </select> |
| #363 | </FormField> |
| #364 | {form.applyMethod === "external" && ( |
| #365 | <FormField label="External Application Link" required> |
| #366 | <TextInput value={form.externalLink} onChange={(v) => setField("externalLink", v)} placeholder="https://careers.example.org/apply" /> |
| #367 | </FormField> |
| #368 | )} |
| #369 | |
| #370 | <div className="reg-form-actions"> |
| #371 | <button type="button" className="btn btn-outline" onClick={onClose}>Cancel</button> |
| #372 | <button |
| #373 | type="submit" |
| #374 | className="btn btn-primary" |
| #375 | disabled={ |
| #376 | submitting || |
| #377 | !form.title || |
| #378 | !form.type || |
| #379 | !form.country || |
| #380 | !form.city || |
| #381 | !form.startDate || |
| #382 | !form.duration || |
| #383 | !form.deadline || |
| #384 | !form.responsibilities || |
| #385 | (form.applyMethod === "external" && !form.externalLink) |
| #386 | } |
| #387 | > |
| #388 | {submitting ? "Publishing..." : "Publish Opportunity"} |
| #389 | </button> |
| #390 | </div> |
| #391 | </form> |
| #392 | </div> |
| #393 | </div> |
| #394 | ); |
| #395 | } |
| #396 | |
| #397 | // ---- Organisation Profile Types & Data ---- |
| #398 | |
| #399 | interface OrgProfile { |
| #400 | name: string; |
| #401 | type: string; |
| #402 | size: string; |
| #403 | country: string; |
| #404 | city: string; |
| #405 | website: string; |
| #406 | contactName: string; |
| #407 | contactEmail: string; |
| #408 | contactPhone: string; |
| #409 | description: string; |
| #410 | sectors: string[]; |
| #411 | avatar: string; |
| #412 | logoUrl: string; |
| #413 | } |
| #414 | |
| #415 | const EMPTY_ORG_PROFILE: OrgProfile = { |
| #416 | name: "", |
| #417 | type: "", |
| #418 | size: "", |
| #419 | country: "", |
| #420 | city: "", |
| #421 | website: "", |
| #422 | contactName: "", |
| #423 | contactEmail: "", |
| #424 | contactPhone: "", |
| #425 | description: "", |
| #426 | sectors: [], |
| #427 | avatar: "O", |
| #428 | logoUrl: "", |
| #429 | }; |
| #430 | |
| #431 | // ---- Dashboard stat card ---- |
| #432 | |
| #433 | function StatCard({ icon, value, label, color }: { icon: string; value: string | number; label: string; color: string }) { |
| #434 | return ( |
| #435 | <div className="dash-stat-card"> |
| #436 | <div className="dash-stat-icon" style={{ background: color }}>{icon}</div> |
| #437 | <div className="dash-stat-info"> |
| #438 | <span className="dash-stat-value">{value}</span> |
| #439 | <span className="dash-stat-label">{label}</span> |
| #440 | </div> |
| #441 | </div> |
| #442 | ); |
| #443 | } |
| #444 | |
| #445 | // ---- Main Dashboard ---- |
| #446 | |
| #447 | interface DashboardProps { |
| #448 | userId: string | null; |
| #449 | onBack: () => void; |
| #450 | } |
| #451 | |
| #452 | export default function OrganisationDashboard({ userId, onBack }: DashboardProps) { |
| #453 | const [activeTab, setActiveTab] = useState<"overview" | "opportunities" | "applicants" | "settings">("overview"); |
| #454 | const [showPostForm, setShowPostForm] = useState(false); |
| #455 | const [showEditProfile, setShowEditProfile] = useState(false); |
| #456 | const [showEditOpportunity, setShowEditOpportunity] = useState<PostedOpportunity | null>(null); |
| #457 | const [applicantFilter, setApplicantFilter] = useState<string>("All"); |
| #458 | const [sidebarOpen, setSidebarOpen] = useState(false); |
| #459 | const [orgProfile, setOrgProfile] = useState<OrgProfile>({ ...EMPTY_ORG_PROFILE }); |
| #460 | const [orgOpportunities, setOrgOpportunities] = useState(EMPTY_OPPORTUNITIES); |
| #461 | const [orgApplicants, setOrgApplicants] = useState(EMPTY_APPLICANTS); |
| #462 | const [loading, setLoading] = useState(!!userId); |
| #463 | const [verifiedStatus, setVerifiedStatus] = useState<boolean>(false); |
| #464 | const [expandedApplicant, setExpandedApplicant] = useState<string | null>(null); |
| #465 | |
| #466 | const filteredApplicants = applicantFilter === "All" |
| #467 | ? orgApplicants |
| #468 | : orgApplicants.filter((a) => a.status === applicantFilter); |
| #469 | |
| #470 | // Fetch real data from Supabase when userId is available |
| #471 | useEffect(() => { |
| #472 | if (!userId) { |
| #473 | setLoading(false); |
| #474 | return; |
| #475 | } |
| #476 | |
| #477 | async function fetchData() { |
| #478 | try { |
| #479 | // Fetch organisation profile |
| #480 | const { data: profileData } = await supabase |
| #481 | .from("organisation_profiles") |
| #482 | .select("*") |
| #483 | .eq("user_id", userId) |
| #484 | .single(); |
| #485 | |
| #486 | if (profileData) { |
| #487 | setOrgProfile({ |
| #488 | name: profileData.org_name || "", |
| #489 | type: profileData.org_type || "", |
| #490 | size: profileData.num_employees || "", |
| #491 | country: profileData.country || "", |
| #492 | city: profileData.region_city || "", |
| #493 | website: profileData.website || "", |
| #494 | contactName: profileData.contact_name || "", |
| #495 | contactEmail: profileData.contact_email || "", |
| #496 | contactPhone: profileData.org_phone || "", |
| #497 | description: profileData.org_description || profileData.description || "", |
| #498 | sectors: profileData.sector ? [profileData.sector] : [], |
| #499 | avatar: (profileData.org_name || "O").slice(0, 2).toUpperCase(), |
| #500 | logoUrl: profileData.logo_url || "", |
| #501 | }); |
| #502 | setVerifiedStatus(profileData.verified === true); |
| #503 | } |
| #504 | |
| #505 | // Fetch organisation's opportunities |
| #506 | const { data: oppsData } = await supabase |
| #507 | .from("opportunities") |
| #508 | .select("*") |
| #509 | .eq("org_user_id", userId) |
| #510 | .order("posted_at", { ascending: false }); |
| #511 | |
| #512 | if (oppsData && oppsData.length > 0) { |
| #513 | setOrgOpportunities(oppsData.map((o: Record<string, unknown>) => ({ |
| #514 | id: o.id as string, |
| #515 | title: (o.title as string) || "", |
| #516 | type: (o.type as string) || "", |
| #517 | location: (o.location as string) || "", |
| #518 | posted: (o.posted_at as string)?.slice(0, 10) || "", |
| #519 | deadline: (o.deadline as string) || "", |
| #520 | applicants: (o.applicants_count as number) || 0, |
| #521 | slots: (o.slots_available as number) || 0, |
| #522 | paidStatus: (o.paid_status as string) || "", |
| #523 | stipendAmount: (o.stipend_amount as string) || "", |
| #524 | responsibilities: (o.responsibilities as string) || "", |
| #525 | learningOutcomes: (o.learning_outcomes as string) || "", |
| #526 | expectations: (o.expectations as string) || "", |
| #527 | benefits: (o.benefits as string) || "", |
| #528 | externalLink: (o.external_link as string) || "", |
| #529 | description: (o.description as string) || "", |
| #530 | duration: (o.duration as string) || "", |
| #531 | skills: (o.skills as string[]) || [], |
| #532 | status: ((o.status as string) || "Active") as "Active" | "Closed" | "Draft", |
| #533 | }))); |
| #534 | } |
| #535 | |
| #536 | // Fetch applicants with CV via join |
| #537 | // First try filtering by org_user_id |
| #538 | let appsData: Record<string, unknown>[] = []; |
| #539 | const { data: byOrgUser, error: e1 } = await supabase |
| #540 | .from("youth_applications") |
| #541 | .select("*, youth_profiles(cv_url, graduation_year, full_name, university, programme)") |
| #542 | .eq("org_user_id", userId) |
| #543 | .order("applied_date", { ascending: false }); |
| #544 | |
| #545 | if (byOrgUser && byOrgUser.length > 0) { |
| #546 | appsData = byOrgUser; |
| #547 | } else { |
| #548 | // Fallback: fetch by opportunity IDs |
| #549 | const oppIds = orgOpportunities.map((o) => o.id); |
| #550 | if (oppIds.length > 0) { |
| #551 | const { data: byOpp, error: e2 } = await supabase |
| #552 | .from("youth_applications") |
| #553 | .select("*, youth_profiles(cv_url, graduation_year, full_name, university, programme)") |
| #554 | .in("opportunity_id", oppIds) |
| #555 | .order("applied_date", { ascending: false }); |
| #556 | appsData = byOpp || []; |
| #557 | if (e2) console.error("Fetch by opportunity error:", e2); |
| #558 | } |
| #559 | } |
| #560 | if (e1) console.error("Fetch by org_user_id error:", e1); |
| #561 | console.log("appsData:", JSON.stringify(appsData, null, 2)); |
| #562 | |
| #563 | setOrgApplicants(appsData.map((a: Record<string, unknown>) => { |
| #564 | const uid = (a.user_id as string) || ""; |
| #565 | const profile = a.youth_profiles as Record<string, unknown> | null; |
| #566 | return { |
| #567 | id: a.id as string, |
| #568 | name: (profile?.full_name as string) || (a.applicant_name as string) || "", |
| #569 | email: (a.applicant_email as string) || "", |
| #570 | university: (profile?.university as string) || (a.university as string) || "", |
| #571 | course: (profile?.programme as string) || (a.programme as string) || "", |
| #572 | graduationYear: (profile?.graduation_year as string) || (a.graduation_year as string) || "", |
| #573 | appliedFor: (a.opportunity_title as string) || "", |
| #574 | opportunityId: String(a.opportunity_id || ""), |
| #575 | date: (a.applied_date as string)?.slice(0, 10) || "", |
| #576 | status: ((a.status as string) || "New") as "New" | "Reviewed" | "Shortlisted" | "Selected" | "Rejected", |
| #577 | cvUrl: (profile?.cv_url as string) || "", |
| #578 | coverLetterUrl: (a.cover_letter_url as string) || "", |
| #579 | transcriptUrl: (a.transcript_url as string) || "", |
| #580 | userId: uid, |
| #581 | }; |
| #582 | })); |
| #583 | } catch (err) { |
| #584 | console.error("Error fetching org dashboard data:", err); |
| #585 | } finally { |
| #586 | setLoading(false); |
| #587 | } |
| #588 | } |
| #589 | |
| #590 | fetchData(); |
| #591 | }, [userId]); |
| #592 | |
| #593 | const [showContactModal, setShowContactModal] = useState<Applicant | null>(null); |
| #594 | const [contactMessage, setContactMessage] = useState(""); |
| #595 | const [contactSubject, setContactSubject] = useState(""); |
| #596 | const [logoUploading, setLogoUploading] = useState(false); |
| #597 | |
| #598 | const [logoMessage, setLogoMessage] = useState<string | null>(null); |
| #599 | |
| #600 | const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| #601 | const file = e.target.files?.[0]; |
| #602 | if (!file || !userId) return; |
| #603 | setLogoUploading(true); |
| #604 | setLogoMessage(null); |
| #605 | |
| #606 | try { |
| #607 | const filePath = `org-logos/${userId}/${Date.now()}_${file.name}`; |
| #608 | const { error: uploadError } = await supabase.storage |
| #609 | .from("documents") |
| #610 | .upload(filePath, file, { upsert: true }); |
| #611 | if (uploadError) { |
| #612 | console.error("Logo upload failed:", uploadError); |
| #613 | setLogoMessage("Upload failed: " + uploadError.message); |
| #614 | setLogoUploading(false); |
| #615 | return; |
| #616 | } |
| #617 | |
| #618 | const { data: urlData } = supabase.storage.from("documents").getPublicUrl(filePath); |
| #619 | const logoUrl = urlData.publicUrl; |
| #620 | console.log("Logo uploaded to:", logoUrl); |
| #621 | |
| #622 | const { data: updateData, error: updateError } = await supabase |
| #623 | .from("organisation_profiles") |
| #624 | .update({ logo_url: logoUrl }) |
| #625 | .eq("user_id", userId) |
| #626 | .select(); |
| #627 | |
| #628 | console.log("Logo DB update:", { updateData, updateError }); |
| #629 | |
| #630 | if (updateError) { |
| #631 | setLogoMessage("Upload succeeded but failed to save: " + updateError.message); |
| #632 | } else if (!updateData || updateData.length === 0) { |
| #633 | setLogoMessage("Upload succeeded but no rows updated. Check RLS policy."); |
| #634 | } else { |
| #635 | setOrgProfile((prev) => ({ ...prev, logoUrl })); |
| #636 | setLogoMessage("Logo uploaded successfully!"); |
| #637 | } |
| #638 | } catch (err) { |
| #639 | console.error("Logo upload error:", err); |
| #640 | setLogoMessage("Upload failed. Please try again."); |
| #641 | } finally { |
| #642 | setLogoUploading(false); |
| #643 | } |
| #644 | }; |
| #645 | |
| #646 | // Re-fetch verified status when overview tab is shown |
| #647 | useEffect(() => { |
| #648 | if (!userId || activeTab !== "overview") return; |
| #649 | supabase |
| #650 | .from("organisation_profiles") |
| #651 | .select("verified") |
| #652 | .eq("user_id", userId) |
| #653 | .single() |
| #654 | .then(({ data }) => { |
| #655 | if (data) setVerifiedStatus(data.verified === true); |
| #656 | }); |
| #657 | }, [userId, activeTab]); |
| #658 | |
| #659 | const handleApplicantStatus = async ( |
| #660 | applicantId: string, |
| #661 | newStatus: "Shortlisted" | "Selected" | "Reviewed", |
| #662 | youthUserId?: string, |
| #663 | opportunityTitle?: string |
| #664 | ) => { |
| #665 | // Update local state immediately |
| #666 | setOrgApplicants((prev) => |
| #667 | prev.map((a) => (a.id === applicantId ? { ...a, status: newStatus } : a)) |
| #668 | ); |
| #669 | |
| #670 | // Update in Supabase |
| #671 | if (userId) { |
| #672 | const { error } = await supabase |
| #673 | .from("youth_applications") |
| #674 | .update({ status: newStatus }) |
| #675 | .eq("id", applicantId) |
| #676 | .eq("org_user_id", userId); |
| #677 | |
| #678 | if (error) { |
| #679 | console.error("Failed to update applicant status:", error); |
| #680 | } |
| #681 | } |
| #682 | |
| #683 | // Create notification for the youth user (only for upgrades, not reversals) |
| #684 | if (youthUserId && (newStatus === "Shortlisted" || newStatus === "Selected")) { |
| #685 | const message = newStatus === "Shortlisted" |
| #686 | ? `Congratulations! You have been shortlisted by ${orgProfile.name} for "${opportunityTitle}".` |
| #687 | : `Great news! You have been selected by ${orgProfile.name} for "${opportunityTitle}".`; |
| #688 | |
| #689 | await supabase.from("youth_notifications").insert({ |
| #690 | user_id: youthUserId, |
| #691 | type: newStatus === "Shortlisted" ? "shortlisted" : "selected", |
| #692 | title: newStatus === "Shortlisted" ? "Shortlisted!" : "Selected!", |
| #693 | message: message, |
| #694 | read: false, |
| #695 | }); |
| #696 | } |
| #697 | }; |
| #698 | |
| #699 | const handleSendMessage = async () => { |
| #700 | if (!showContactModal || !contactSubject || !contactMessage) return; |
| #701 | |
| #702 | const youthUserId = showContactModal.userId; |
| #703 | console.log("Sending message to youth user:", youthUserId); |
| #704 | console.log("Contact modal data:", showContactModal); |
| #705 | |
| #706 | if (!youthUserId) { |
| #707 | console.error("No youth user ID found for this applicant"); |
| #708 | return; |
| #709 | } |
| #710 | |
| #711 | const { error } = await supabase.from("youth_notifications").insert({ |
| #712 | user_id: youthUserId, |
| #713 | type: "message", |
| #714 | title: contactSubject, |
| #715 | message: `${orgProfile.name}: ${contactMessage}`, |
| #716 | read: false, |
| #717 | }); |
| #718 | |
| #719 | if (error) { |
| #720 | console.error("Failed to send message:", error); |
| #721 | } else { |
| #722 | console.log("Message sent successfully to", youthUserId); |
| #723 | } |
| #724 | |
| #725 | setShowContactModal(null); |
| #726 | setContactSubject(""); |
| #727 | setContactMessage(""); |
| #728 | }; |
| #729 | |
| #730 | return ( |
| #731 | <div className="dash-page"> |
| #732 | {/* Top Nav */} |
| #733 | <nav className="nav"> |
| #734 | <div className="nav-inner"> |
| #735 | <div className="dash-nav-left"> |
| #736 | <button type="button" className="dash-sidebar-toggle" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Toggle menu"> |
| #737 | {sidebarOpen ? "✕" : "☰"} |
| #738 | </button> |
| #739 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #740 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #741 | <span className="logo-text">Fursa<span className="logo-highlight">Link</span></span> |
| #742 | </a> |
| #743 | </div> |
| #744 | <div className="dash-nav-tabs"> |
| #745 | <button |
| #746 | className={`dash-nav-tab ${activeTab === "overview" ? "dash-nav-tab-active" : ""}`} |
| #747 | onClick={() => setActiveTab("overview")} |
| #748 | > |
| #749 | Overview |
| #750 | </button> |
| #751 | <button |
| #752 | className={`dash-nav-tab ${activeTab === "opportunities" ? "dash-nav-tab-active" : ""}`} |
| #753 | onClick={() => setActiveTab("opportunities")} |
| #754 | > |
| #755 | Opportunities |
| #756 | </button> |
| #757 | <button |
| #758 | className={`dash-nav-tab ${activeTab === "applicants" ? "dash-nav-tab-active" : ""}`} |
| #759 | onClick={() => setActiveTab("applicants")} |
| #760 | > |
| #761 | Applicants |
| #762 | </button> |
| #763 | </div> |
| #764 | <div className="nav-actions"> |
| #765 | <button type="button" className="btn btn-outline btn-sm" onClick={onBack}> |
| #766 | Sign Out |
| #767 | </button> |
| #768 | </div> |
| #769 | </div> |
| #770 | </nav> |
| #771 | |
| #772 | {/* Mobile sidebar */} |
| #773 | {sidebarOpen && ( |
| #774 | <div className="dash-mobile-overlay" onClick={() => setSidebarOpen(false)} /> |
| #775 | )} |
| #776 | <aside className={`dash-sidebar ${sidebarOpen ? "dash-sidebar-open" : ""}`}> |
| #777 | <div className="dash-sidebar-user"> |
| #778 | <div className="dash-sidebar-org-icon"> |
| #779 | {orgProfile.logoUrl ? ( |
| #780 | <img src={orgProfile.logoUrl} alt="Logo" style={{ width: "3.5rem", height: "3.5rem", borderRadius: "50%", objectFit: "cover" }} /> |
| #781 | ) : "🏢"} |
| #782 | </div> |
| #783 | <div> |
| #784 | <span className="dash-sidebar-name">{orgProfile.name || "Organisation"}</span> |
| #785 | {verifiedStatus === true && <span className="dash-verified-badge" style={{ marginTop: "0.25rem", display: "inline-flex", background: "#d1fae5", color: "#065f46" }}>✓ Verified</span>} |
| #786 | {verifiedStatus !== true && <span className="dash-verified-badge" style={{ marginTop: "0.25rem", display: "inline-flex", background: "#fef3c7", color: "#92400e" }}>⏳ Verification Pending</span>} |
| #787 | </div> |
| #788 | </div> |
| #789 | <nav className="dash-sidebar-nav"> |
| #790 | {[ |
| #791 | { key: "overview" as const, label: "Overview", icon: "📊" }, |
| #792 | { key: "opportunities" as const, label: "Opportunities", icon: "📋" }, |
| #793 | { key: "applicants" as const, label: "Applicants", icon: "👥" }, |
| #794 | ].map((tab) => ( |
| #795 | <button |
| #796 | key={tab.key} |
| #797 | className={`dash-sidebar-link ${activeTab === tab.key ? "dash-sidebar-link-active" : ""}`} |
| #798 | onClick={() => { setActiveTab(tab.key); setSidebarOpen(false); }} |
| #799 | > |
| #800 | <span className="dash-sidebar-icon">{tab.icon}</span> |
| #801 | <span>{tab.label}</span> |
| #802 | </button> |
| #803 | ))} |
| #804 | </nav> |
| #805 | <div className="dash-sidebar-footer"> |
| #806 | <button type="button" className="dash-sidebar-link" onClick={onBack}> |
| #807 | <span className="dash-sidebar-icon">🚪</span> |
| #808 | <span>Sign Out</span> |
| #809 | </button> |
| #810 | </div> |
| #811 | </aside> |
| #812 | |
| #813 | <div className="dash-container"> |
| #814 | {/* Dashboard Header */} |
| #815 | <div className="dash-header"> |
| #816 | <div> |
| #817 | <div className="dash-org-name"> |
| #818 | <h1>{orgProfile.name || "Organisation"}</h1> |
| #819 | {verifiedStatus === true && <span className="dash-verified-badge" style={{ background: "#d1fae5", color: "#065f46" }}>✓ Verified Organisation</span>} |
| #820 | {verifiedStatus !== true && <span className="dash-verified-badge" style={{ background: "#fef3c7", color: "#92400e" }}>⏳ Verification Pending</span>} |
| #821 | </div> |
| #822 | <p className="dash-org-meta">{orgProfile.type || "Organisation"} · {orgProfile.city || ""}{orgProfile.city && orgProfile.country ? ", " : ""}{orgProfile.country || ""}</p> |
| #823 | </div> |
| #824 | <div style={{ display: "flex", gap: "0.75rem" }}> |
| #825 | <label className="btn btn-outline" style={{ cursor: "pointer", margin: 0 }}> |
| #826 | {logoUploading ? "Uploading..." : orgProfile.logoUrl ? "Change Logo" : "Upload Logo"} |
| #827 | <input type="file" accept=".png,.jpg,.jpeg,.svg" style={{ display: "none" }} onChange={handleLogoUpload} disabled={logoUploading} /> |
| #828 | </label> |
| #829 | <button |
| #830 | type="button" |
| #831 | className="btn btn-outline" |
| #832 | onClick={() => setShowEditProfile(true)} |
| #833 | > |
| #834 | Edit Profile |
| #835 | </button> |
| #836 | <button |
| #837 | type="button" |
| #838 | className="btn btn-primary" |
| #839 | onClick={() => setShowPostForm(true)} |
| #840 | > |
| #841 | + Post New Opportunity |
| #842 | </button> |
| #843 | </div> |
| #844 | </div> |
| #845 | {logoMessage && ( |
| #846 | <div style={{ padding: "0.75rem", borderRadius: "0.5rem", fontSize: "0.875rem", marginBottom: "1rem", background: logoMessage.includes("success") ? "#d1fae5" : "#fee2e2", color: logoMessage.includes("success") ? "#065f46" : "#991b1b" }}> |
| #847 | {logoMessage} |
| #848 | </div> |
| #849 | )} |
| #850 | |
| #851 | {/* Overview Tab */} |
| #852 | {activeTab === "overview" && ( |
| #853 | <div className="dash-content"> |
| #854 | <div className="dash-stats-grid"> |
| #855 | <StatCard icon="📋" value={orgOpportunities.filter((o) => o.status === "Active").length} label="Active Opportunities" color="var(--green-100)" /> |
| #856 | <StatCard icon="👥" value={orgApplicants.length} label="Total Applicants" color="var(--blue-100, #dbeafe)" /> |
| #857 | <StatCard icon="⭐" value={orgApplicants.filter((a) => a.status === "Shortlisted" || a.status === "Selected").length} label="Shortlisted / Selected" color="var(--gold-100, #fef9c3)" /> |
| #858 | <StatCard icon="📊" label="Opportunities Posted" value={orgOpportunities.length} color="var(--purple-100, #f3e8ff)" /> |
| #859 | </div> |
| #860 | |
| #861 | {verifiedStatus !== true && ( |
| #862 | <div className="dash-card" style={{ borderLeft: "4px solid #f59e0b", marginBottom: "1.5rem" }}> |
| #863 | <h3 className="dash-card-title" style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> |
| #864 | 🛡️ Verify Your Organisation |
| #865 | </h3> |
| #866 | <p style={{ color: "#374151", fontSize: "0.9rem", marginBottom: "0.75rem" }}> |
| #867 | Your organisation will be verified via your official email address. A verification link will be sent to <strong>{orgProfile.contactEmail || orgProfile.name}</strong> after registration. |
| #868 | </p> |
| #869 | <p style={{ color: "#6b7280", fontSize: "0.85rem", marginBottom: "1rem" }}> |
| #870 | Once verified you will receive a trusted badge visible to all youth applicants. |
| #871 | </p> |
| #872 | <button |
| #873 | type="button" |
| #874 | className="btn btn-primary" |
| #875 | onClick={() => { |
| #876 | alert("Verification email will be sent to your registered email address. Please check your inbox."); |
| #877 | }} |
| #878 | > |
| #879 | ✉️ Verify Your Organisation |
| #880 | </button> |
| #881 | </div> |
| #882 | )} |
| #883 | |
| #884 | <div className="dash-grid-2"> |
| #885 | <div className="dash-card"> |
| #886 | <h3 className="dash-card-title">Recent Applicants</h3> |
| #887 | <div className="dash-applicant-list"> |
| #888 | {orgApplicants.slice(0, 4).map((a) => ( |
| #889 | <div key={a.id} className="dash-applicant-row"> |
| #890 | <div className="dash-applicant-avatar">{a.name[0]}</div> |
| #891 | <div className="dash-applicant-info"> |
| #892 | <span className="dash-applicant-name">{a.name}</span> |
| #893 | <span className="dash-applicant-detail">{a.course} · {a.university}</span> |
| #894 | </div> |
| #895 | <span className={`dash-status dash-status-${a.status.toLowerCase()}`}>{a.status}</span> |
| #896 | </div> |
| #897 | ))} |
| #898 | </div> |
| #899 | </div> |
| #900 | <div className="dash-card"> |
| #901 | <h3 className="dash-card-title">Active Opportunities</h3> |
| #902 | <div className="dash-opp-list"> |
| #903 | {orgOpportunities.filter((o) => o.status === "Active").map((o) => ( |
| #904 | <div key={o.id} className="dash-opp-row"> |
| #905 | <div className="dash-opp-info"> |
| #906 | <span className="dash-opp-title">{o.title}</span> |
| #907 | <span className="dash-opp-meta">{o.location} · {o.type}</span> |
| #908 | </div> |
| #909 | <div className="dash-opp-applicants"> |
| #910 | <span className="dash-opp-count">{o.slots}</span> |
| #911 | <span className="dash-opp-count-label">slots</span> |
| #912 | <span className="dash-opp-count" style={{ marginLeft: "0.75rem" }}>{orgApplicants.filter((a) => a.opportunityId === o.id || a.appliedFor === o.title).length}</span> |
| #913 | <span className="dash-opp-count-label">applicants</span> |
| #914 | </div> |
| #915 | </div> |
| #916 | ))} |
| #917 | </div> |
| #918 | </div> |
| #919 | </div> |
| #920 | </div> |
| #921 | )} |
| #922 | |
| #923 | {/* Opportunities Tab */} |
| #924 | {activeTab === "opportunities" && ( |
| #925 | <div className="dash-content"> |
| #926 | <div className="dash-card"> |
| #927 | <div className="dash-card-header"> |
| #928 | <h3 className="dash-card-title">All Opportunities</h3> |
| #929 | <button type="button" className="btn btn-primary btn-sm" onClick={() => setShowPostForm(true)}> |
| #930 | + New Opportunity |
| #931 | </button> |
| #932 | </div> |
| #933 | <div className="dash-table-wrap"> |
| #934 | <table className="dash-table"> |
| #935 | <thead> |
| #936 | <tr> |
| #937 | <th>Title</th> |
| #938 | <th>Type</th> |
| #939 | <th>Location</th> |
| #940 | <th>Deadline</th> |
| #941 | <th>Slots</th> |
| #942 | <th>Applicants</th> |
| #943 | <th>Status</th> |
| #944 | <th>Actions</th> |
| #945 | </tr> |
| #946 | </thead> |
| #947 | <tbody> |
| #948 | {orgOpportunities.map((o) => ( |
| #949 | <tr key={o.id}> |
| #950 | <td className="dash-table-title">{o.title}</td> |
| #951 | <td>{o.type}</td> |
| #952 | <td>{o.location}</td> |
| #953 | <td>{o.deadline}</td> |
| #954 | <td> |
| #955 | <div style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}> |
| #956 | <input |
| #957 | type="number" |
| #958 | min={0} |
| #959 | style={{ width: "3rem", padding: "0.25rem 0.5rem", border: "1px solid #d1d5db", borderRadius: "0.375rem", fontSize: "0.875rem", textAlign: "center" }} |
| #960 | value={o.slots} |
| #961 | onChange={(e) => { |
| #962 | const newSlots = Math.max(0, Number(e.target.value)); |
| #963 | setOrgOpportunities((prev) => |
| #964 | prev.map((opp) => (opp.id === o.id ? { ...opp, slots: newSlots } : opp)) |
| #965 | ); |
| #966 | }} |
| #967 | /> |
| #968 | <button |
| #969 | type="button" |
| #970 | style={{ padding: "0.25rem 0.5rem", fontSize: "0.7rem", background: "#059669", color: "#fff", border: "none", borderRadius: "0.375rem", cursor: "pointer" }} |
| #971 | onClick={async () => { |
| #972 | console.log("Attempting to update slots:", o.slots, "for opportunity id:", o.id); |
| #973 | const { data, error, status, statusText } = await supabase |
| #974 | .from("opportunities") |
| #975 | .update({ slots_available: o.slots }) |
| #976 | .eq("id", o.id) |
| #977 | .select(); |
| #978 | console.log("Update response:", { data, error, status, statusText }); |
| #979 | if (error) { |
| #980 | console.error("Failed to update slots:", error); |
| #981 | alert("Failed to save: " + error.message); |
| #982 | } else if (!data || data.length === 0) { |
| #983 | console.error("No rows updated - ID might not match"); |
| #984 | alert("No rows updated. Check that the opportunity ID exists."); |
| #985 | } else { |
| #986 | console.log("Slots saved successfully:", data); |
| #987 | } |
| #988 | }} |
| #989 | > |
| #990 | Save |
| #991 | </button> |
| #992 | </div> |
| #993 | </td> |
| #994 | <td> |
| #995 | <span className="dash-table-count"> |
| #996 | {orgApplicants.filter((a) => a.opportunityId === o.id || a.appliedFor === o.title).length} |
| #997 | </span> |
| #998 | </td> |
| #999 | <td> |
| #1000 | <span className={`dash-status dash-status-${o.status.toLowerCase()}`}>{o.status}</span> |
| #1001 | </td> |
| #1002 | <td> |
| #1003 | <div className="dash-actions"> |
| #1004 | <button |
| #1005 | type="button" |
| #1006 | className="dash-action-btn" |
| #1007 | title="Edit Opportunity" |
| #1008 | onClick={() => setShowEditOpportunity(o)} |
| #1009 | > |
| #1010 | ✏️ |
| #1011 | </button> |
| #1012 | </div> |
| #1013 | </td> |
| #1014 | </tr> |
| #1015 | ))} |
| #1016 | </tbody> |
| #1017 | </table> |
| #1018 | </div> |
| #1019 | </div> |
| #1020 | </div> |
| #1021 | )} |
| #1022 | |
| #1023 | {/* Applicants Tab */} |
| #1024 | {activeTab === "applicants" && ( |
| #1025 | <div className="dash-content"> |
| #1026 | <div className="dash-card"> |
| #1027 | <div className="dash-card-header"> |
| #1028 | <h3 className="dash-card-title">All Applicants</h3> |
| #1029 | <div className="dash-filter-chips"> |
| #1030 | {["All", "New", "Reviewed", "Shortlisted", "Selected", "Rejected"].map((f) => ( |
| #1031 | <button |
| #1032 | key={f} |
| #1033 | type="button" |
| #1034 | className={`reg-chip ${applicantFilter === f ? "reg-chip-active" : ""}`} |
| #1035 | onClick={() => setApplicantFilter(f)} |
| #1036 | > |
| #1037 | {f} |
| #1038 | </button> |
| #1039 | ))} |
| #1040 | </div> |
| #1041 | </div> |
| #1042 | <div className="dash-table-wrap"> |
| #1043 | <table className="dash-table"> |
| #1044 | <thead> |
| #1045 | <tr> |
| #1046 | <th>Name</th> |
| #1047 | <th>University</th> |
| #1048 | <th>Course</th> |
| #1049 | <th>Graduation Year</th> |
| #1050 | <th>Applied For</th> |
| #1051 | <th>Date</th> |
| #1052 | <th>Status</th> |
| #1053 | <th>Actions</th> |
| #1054 | </tr> |
| #1055 | </thead> |
| #1056 | <tbody> |
| #1057 | {filteredApplicants.map((a) => ( |
| #1058 | <> |
| #1059 | <tr key={a.id}> |
| #1060 | <td className="dash-table-title">{a.name}</td> |
| #1061 | <td>{a.university}</td> |
| #1062 | <td>{a.course}</td> |
| #1063 | <td>{a.graduationYear || "—"}</td> |
| #1064 | <td>{a.appliedFor}</td> |
| #1065 | <td>{a.date}</td> |
| #1066 | <td> |
| #1067 | <span className={`dash-status dash-status-${a.status.toLowerCase()}`}>{a.status}</span> |
| #1068 | </td> |
| #1069 | <td> |
| #1070 | <div className="dash-actions"> |
| #1071 | {a.cvUrl ? ( |
| #1072 | <a |
| #1073 | href={a.cvUrl} |
| #1074 | target="_blank" |
| #1075 | rel="noopener noreferrer" |
| #1076 | className="dash-action-btn" |
| #1077 | title="CV" |
| #1078 | style={{ background: "#d1fae5", color: "#065f46", borderRadius: "0.375rem", padding: "0.25rem 0.5rem", fontSize: "0.75rem", fontWeight: 600, textDecoration: "none" }} |
| #1079 | > |
| #1080 | CV |
| #1081 | </a> |
| #1082 | ) : ( |
| #1083 | <span |
| #1084 | style={{ color: "#9ca3af", fontSize: "0.75rem", padding: "0.25rem 0.5rem" }} |
| #1085 | > |
| #1086 | No CV |
| #1087 | </span> |
| #1088 | )} |
| #1089 | <button |
| #1090 | type="button" |
| #1091 | className="dash-action-btn" |
| #1092 | title={expandedApplicant === a.id ? "Hide Details" : "View Details & CV"} |
| #1093 | onClick={() => setExpandedApplicant(expandedApplicant === a.id ? null : a.id)} |
| #1094 | > |
| #1095 | {expandedApplicant === a.id ? "▲" : "▼"} |
| #1096 | </button> |
| #1097 | {a.status === "Shortlisted" ? ( |
| #1098 | <button |
| #1099 | type="button" |
| #1100 | className="dash-action-btn" |
| #1101 | title="Undo Shortlist" |
| #1102 | onClick={() => handleApplicantStatus(a.id, "Reviewed", a.userId, a.appliedFor)} |
| #1103 | > |
| #1104 | ↩️ |
| #1105 | </button> |
| #1106 | ) : a.status !== "Selected" && ( |
| #1107 | <button |
| #1108 | type="button" |
| #1109 | className="dash-action-btn" |
| #1110 | title="Shortlist" |
| #1111 | onClick={() => handleApplicantStatus(a.id, "Shortlisted", a.userId, a.appliedFor)} |
| #1112 | > |
| #1113 | ⭐ |
| #1114 | </button> |
| #1115 | )} |
| #1116 | {a.status === "Selected" ? ( |
| #1117 | <button |
| #1118 | type="button" |
| #1119 | className="dash-action-btn" |
| #1120 | title="Undo Selection" |
| #1121 | onClick={() => handleApplicantStatus(a.id, "Reviewed", a.userId, a.appliedFor)} |
| #1122 | > |
| #1123 | ↩️ |
| #1124 | </button> |
| #1125 | ) : ( |
| #1126 | <button |
| #1127 | type="button" |
| #1128 | className="dash-action-btn" |
| #1129 | title="Select" |
| #1130 | onClick={() => handleApplicantStatus(a.id, "Selected", a.userId, a.appliedFor)} |
| #1131 | > |
| #1132 | ✅ |
| #1133 | </button> |
| #1134 | )} |
| #1135 | {a.status === "Selected" && ( |
| #1136 | <button |
| #1137 | type="button" |
| #1138 | className="dash-action-btn" |
| #1139 | title="Contact Applicant" |
| #1140 | onClick={() => setShowContactModal(a)} |
| #1141 | > |
| #1142 | ✉️ |
| #1143 | </button> |
| #1144 | )} |
| #1145 | </div> |
| #1146 | </td> |
| #1147 | </tr> |
| #1148 | {expandedApplicant === a.id && ( |
| #1149 | <tr key={`${a.id}-details`}> |
| #1150 | <td colSpan={8} style={{ background: "#f8fafc", padding: "1rem 1.5rem" }}> |
| #1151 | <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> |
| #1152 | <div> |
| #1153 | <h4 style={{ margin: "0 0 0.75rem", fontSize: "0.875rem", fontWeight: 600, color: "#374151" }}>Applicant Details</h4> |
| #1154 | <div style={{ display: "grid", gap: "0.5rem", fontSize: "0.875rem" }}> |
| #1155 | <div><span style={{ color: "#6b7280" }}>Name:</span> <strong>{a.name}</strong></div> |
| #1156 | {a.email && <div><span style={{ color: "#6b7280" }}>Email:</span> {a.email}</div>} |
| #1157 | <div><span style={{ color: "#6b7280" }}>University:</span> {a.university}</div> |
| #1158 | <div><span style={{ color: "#6b7280" }}>Course:</span> {a.course}</div> |
| #1159 | <div><span style={{ color: "#6b7280" }}>Applied For:</span> {a.appliedFor}</div> |
| #1160 | <div><span style={{ color: "#6b7280" }}>Date:</span> {a.date}</div> |
| #1161 | <div> |
| #1162 | <span style={{ color: "#6b7280" }}>Graduation Year: </span> |
| #1163 | <strong>{a.graduationYear || "—"}</strong> |
| #1164 | </div> |
| #1165 | </div> |
| #1166 | </div> |
| #1167 | <div> |
| #1168 | <h4 style={{ margin: "0 0 0.75rem", fontSize: "0.875rem", fontWeight: 600, color: "#374151" }}>Documents</h4> |
| #1169 | <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}> |
| #1170 | {/* CV */} |
| #1171 | <div style={{ padding: "1rem", background: "white", borderRadius: "0.5rem", border: "1px solid #e5e7eb", display: "flex", justifyContent: "space-between", alignItems: "center" }}> |
| #1172 | <div> |
| #1173 | <span style={{ fontSize: "0.85rem", fontWeight: 600 }}>📄 CV / Resume</span> |
| #1174 | <p style={{ fontSize: "0.75rem", color: "#9ca3af", margin: 0 }}>{a.cvUrl ? "Uploaded" : "Not uploaded"}</p> |
| #1175 | </div> |
| #1176 | {a.cvUrl ? ( |
| #1177 | <a href={a.cvUrl} target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">View</a> |
| #1178 | ) : ( |
| #1179 | <span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>—</span> |
| #1180 | )} |
| #1181 | </div> |
| #1182 | {/* Cover Letter */} |
| #1183 | <div style={{ padding: "1rem", background: "white", borderRadius: "0.5rem", border: "1px solid #e5e7eb", display: "flex", justifyContent: "space-between", alignItems: "center" }}> |
| #1184 | <div> |
| #1185 | <span style={{ fontSize: "0.85rem", fontWeight: 600 }}>📝 Cover Letter</span> |
| #1186 | <p style={{ fontSize: "0.75rem", color: "#9ca3af", margin: 0 }}>{a.coverLetterUrl ? "Uploaded" : "Not provided"}</p> |
| #1187 | </div> |
| #1188 | {a.coverLetterUrl ? ( |
| #1189 | <a href={a.coverLetterUrl} target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">View</a> |
| #1190 | ) : ( |
| #1191 | <span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>—</span> |
| #1192 | )} |
| #1193 | </div> |
| #1194 | {/* Transcript */} |
| #1195 | <div style={{ padding: "1rem", background: "white", borderRadius: "0.5rem", border: "1px solid #e5e7eb", display: "flex", justifyContent: "space-between", alignItems: "center" }}> |
| #1196 | <div> |
| #1197 | <span style={{ fontSize: "0.85rem", fontWeight: 600 }}>📋 Academic Transcript</span> |
| #1198 | <p style={{ fontSize: "0.75rem", color: "#9ca3af", margin: 0 }}>{a.transcriptUrl ? "Uploaded" : "Not provided"}</p> |
| #1199 | </div> |
| #1200 | {a.transcriptUrl ? ( |
| #1201 | <a href={a.transcriptUrl} target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">View</a> |
| #1202 | ) : ( |
| #1203 | <span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>—</span> |
| #1204 | )} |
| #1205 | </div> |
| #1206 | </div> |
| #1207 | </div> |
| #1208 | </div> |
| #1209 | </td> |
| #1210 | </tr> |
| #1211 | )} |
| #1212 | </> |
| #1213 | ))} |
| #1214 | </tbody> |
| #1215 | </table> |
| #1216 | </div> |
| #1217 | </div> |
| #1218 | </div> |
| #1219 | )} |
| #1220 | </div> |
| #1221 | |
| #1222 | {/* Post Opportunity Modal */} |
| #1223 | {showPostForm && ( |
| #1224 | <OpportunityPostingForm |
| #1225 | onClose={() => setShowPostForm(false)} |
| #1226 | orgName={orgProfile.name} |
| #1227 | userId={userId} |
| #1228 | onPosted={async () => { |
| #1229 | setShowPostForm(false); |
| #1230 | if (userId) { |
| #1231 | const { data } = await supabase |
| #1232 | .from("opportunities") |
| #1233 | .select("*") |
| #1234 | .eq("org_user_id", userId) |
| #1235 | .order("posted_at", { ascending: false }); |
| #1236 | if (data) { |
| #1237 | setOrgOpportunities(data.map((o: Record<string, unknown>) => ({ |
| #1238 | id: o.id as string, |
| #1239 | title: (o.title as string) || "", |
| #1240 | type: (o.type as string) || "", |
| #1241 | location: (o.location as string) || "", |
| #1242 | posted: (o.posted_at as string)?.slice(0, 10) || "", |
| #1243 | deadline: (o.deadline as string) || "", |
| #1244 | applicants: (o.applicants_count as number) || 0, |
| #1245 | slots: (o.slots_available as number) || 0, |
| #1246 | status: ((o.status as string) || "Active") as "Active" | "Closed" | "Draft", |
| #1247 | }))); |
| #1248 | } |
| #1249 | } |
| #1250 | }} |
| #1251 | /> |
| #1252 | )} |
| #1253 | |
| #1254 | {/* Edit Profile Modal */} |
| #1255 | {showEditProfile && ( |
| #1256 | <OrgEditProfileModal |
| #1257 | profile={orgProfile} |
| #1258 | onClose={() => setShowEditProfile(false)} |
| #1259 | onSave={async (updated) => { |
| #1260 | setShowEditProfile(false); |
| #1261 | if (userId) { |
| #1262 | console.log("Saving org profile for user:", userId, "Data:", updated); |
| #1263 | const { data: saveData, error: saveError } = await supabase |
| #1264 | .from("organisation_profiles") |
| #1265 | .update({ |
| #1266 | org_name: updated.name, |
| #1267 | org_type: updated.type, |
| #1268 | sector: updated.sectors?.[0] || "", |
| #1269 | country: updated.country, |
| #1270 | region_city: updated.city, |
| #1271 | website: updated.website, |
| #1272 | org_phone: updated.contactPhone, |
| #1273 | contact_name: updated.contactName, |
| #1274 | contact_email: updated.contactEmail, |
| #1275 | org_description: updated.description, |
| #1276 | }) |
| #1277 | .eq("user_id", userId) |
| #1278 | .select(); |
| #1279 | |
| #1280 | console.log("Save result:", { saveData, saveError, rowsUpdated: saveData?.length }); |
| #1281 | |
| #1282 | if (saveError) { |
| #1283 | console.error("Failed to save org profile:", saveError); |
| #1284 | alert("Failed to save: " + saveError.message); |
| #1285 | } else if (!saveData || saveData.length === 0) { |
| #1286 | console.error("No rows updated for user:", userId); |
| #1287 | alert("Profile was not saved. No matching record found."); |
| #1288 | } else { |
| #1289 | console.log("Org profile saved successfully:", saveData); |
| #1290 | setOrgProfile(updated); |
| #1291 | } |
| #1292 | } |
| #1293 | }} |
| #1294 | /> |
| #1295 | )} |
| #1296 | |
| #1297 | {/* Edit Opportunity Modal */} |
| #1298 | {showEditOpportunity && ( |
| #1299 | <EditOpportunityModal |
| #1300 | opportunity={showEditOpportunity} |
| #1301 | onClose={() => setShowEditOpportunity(null)} |
| #1302 | onSave={(updated) => { |
| #1303 | setOrgOpportunities((prev) => |
| #1304 | prev.map((o) => (o.id === updated.id ? updated : o)) |
| #1305 | ); |
| #1306 | setShowEditOpportunity(null); |
| #1307 | }} |
| #1308 | /> |
| #1309 | )} |
| #1310 | |
| #1311 | {/* Contact Applicant Modal */} |
| #1312 | {showContactModal && ( |
| #1313 | <div className="dash-modal-overlay" onClick={() => setShowContactModal(null)}> |
| #1314 | <div className="dash-modal" onClick={(e) => e.stopPropagation()}> |
| #1315 | <div className="dash-modal-header"> |
| #1316 | <h2>Contact {showContactModal.name}</h2> |
| #1317 | <button type="button" className="dash-modal-close" onClick={() => setShowContactModal(null)}>✕</button> |
| #1318 | </div> |
| #1319 | <div className="dash-modal-body"> |
| #1320 | <p style={{ marginBottom: "1rem", color: "var(--gray-600)" }}> |
| #1321 | Send a message to {showContactModal.name} regarding their application for <strong>{showContactModal.appliedFor}</strong>. |
| #1322 | </p> |
| #1323 | <div className="reg-field"> |
| #1324 | <label className="reg-label">Subject <span className="reg-required">*</span></label> |
| #1325 | <input |
| #1326 | type="text" |
| #1327 | className="reg-input" |
| #1328 | placeholder="e.g. Interview Invitation, Office Visit" |
| #1329 | value={contactSubject} |
| #1330 | onChange={(e) => setContactSubject(e.target.value)} |
| #1331 | /> |
| #1332 | </div> |
| #1333 | <div className="reg-field"> |
| #1334 | <label className="reg-label">Message <span className="reg-required">*</span></label> |
| #1335 | <textarea |
| #1336 | className="reg-input" |
| #1337 | rows={5} |
| #1338 | placeholder="Write your message here..." |
| #1339 | value={contactMessage} |
| #1340 | onChange={(e) => setContactMessage(e.target.value)} |
| #1341 | /> |
| #1342 | </div> |
| #1343 | <div className="reg-form-actions"> |
| #1344 | <button type="button" className="btn btn-outline" onClick={() => setShowContactModal(null)}>Cancel</button> |
| #1345 | <button |
| #1346 | type="button" |
| #1347 | className="btn btn-primary" |
| #1348 | disabled={!contactSubject || !contactMessage} |
| #1349 | onClick={handleSendMessage} |
| #1350 | > |
| #1351 | Send Message |
| #1352 | </button> |
| #1353 | </div> |
| #1354 | </div> |
| #1355 | </div> |
| #1356 | </div> |
| #1357 | )} |
| #1358 | |
| #1359 | {/* Loading Overlay */} |
| #1360 | {loading && ( |
| #1361 | <div className="dash-modal-overlay" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}> |
| #1362 | <div style={{ textAlign: "center", color: "white" }}> |
| #1363 | <div style={{ fontSize: "2rem", marginBottom: "1rem" }}>⏳</div> |
| #1364 | <p>Loading your dashboard...</p> |
| #1365 | </div> |
| #1366 | </div> |
| #1367 | )} |
| #1368 | </div> |
| #1369 | ); |
| #1370 | } |
| #1371 | |
| #1372 | // ---- Edit Organisation Profile Modal ---- |
| #1373 | |
| #1374 | function OrgEditProfileModal({ profile, onClose, onSave }: { profile: OrgProfile; onClose: () => void; onSave: (p: OrgProfile) => void }) { |
| #1375 | const [form, setForm] = useState<OrgProfile>({ ...profile }); |
| #1376 | |
| #1377 | const setField = <K extends keyof OrgProfile>(key: K, val: OrgProfile[K]) => { |
| #1378 | setForm((prev) => ({ ...prev, [key]: val })); |
| #1379 | }; |
| #1380 | |
| #1381 | const toggleSector = (sector: string) => { |
| #1382 | setForm((prev) => ({ |
| #1383 | ...prev, |
| #1384 | sectors: prev.sectors.includes(sector) |
| #1385 | ? prev.sectors.filter((s) => s !== sector) |
| #1386 | : [...prev.sectors, sector], |
| #1387 | })); |
| #1388 | }; |
| #1389 | |
| #1390 | const handleSubmit = (e: FormEvent) => { |
| #1391 | e.preventDefault(); |
| #1392 | onSave(form); |
| #1393 | }; |
| #1394 | |
| #1395 | return ( |
| #1396 | <div className="dash-modal-overlay" onClick={onClose}> |
| #1397 | <div className="dash-modal dash-modal-lg" onClick={(e) => e.stopPropagation()}> |
| #1398 | <div className="dash-modal-header"> |
| #1399 | <h2>Edit Organisation Profile</h2> |
| #1400 | <button type="button" className="dash-modal-close" onClick={onClose}>✕</button> |
| #1401 | </div> |
| #1402 | <form onSubmit={handleSubmit} className="dash-modal-body"> |
| #1403 | {/* Organisation Details */} |
| #1404 | <h3 className="reg-sub-heading" style={{ marginTop: 0 }}>Organisation Details</h3> |
| #1405 | <FormField label="Organisation Name" required> |
| #1406 | <input type="text" className="reg-input" value={form.name} onChange={(e) => setField("name", e.target.value)} /> |
| #1407 | </FormField> |
| #1408 | <div className="reg-grid-2"> |
| #1409 | <FormField label="Organisation Type" required> |
| #1410 | <select className="reg-input" value={form.type} onChange={(e) => setField("type", e.target.value)}> |
| #1411 | {ORG_TYPE_OPTIONS.map((t) => <option key={t} value={t}>{t}</option>)} |
| #1412 | </select> |
| #1413 | </FormField> |
| #1414 | <FormField label="Organisation Size"> |
| #1415 | <select className="reg-input" value={form.size} onChange={(e) => setField("size", e.target.value)}> |
| #1416 | <option value="">Select size...</option> |
| #1417 | {ORG_SIZE_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)} |
| #1418 | </select> |
| #1419 | </FormField> |
| #1420 | <FormField label="Country" required> |
| #1421 | <select className="reg-input" value={form.country} onChange={(e) => setField("country", e.target.value)}> |
| #1422 | <option value="">Select country...</option> |
| #1423 | {COUNTRY_OPTIONS.map((c) => <option key={c} value={c}>{c}</option>)} |
| #1424 | </select> |
| #1425 | </FormField> |
| #1426 | <FormField label="City"> |
| #1427 | <input type="text" className="reg-input" value={form.city} onChange={(e) => setField("city", e.target.value)} placeholder="e.g. Nairobi" /> |
| #1428 | </FormField> |
| #1429 | </div> |
| #1430 | <FormField label="Website"> |
| #1431 | <input type="url" className="reg-input" value={form.website} onChange={(e) => setField("website", e.target.value)} placeholder="https://..." /> |
| #1432 | </FormField> |
| #1433 | <FormField label="About the Organisation"> |
| #1434 | <textarea className="reg-input reg-textarea" value={form.description} onChange={(e) => setField("description", e.target.value)} rows={4} placeholder="Describe your organisation's mission and work..." /> |
| #1435 | </FormField> |
| #1436 | |
| #1437 | {/* Sectors */} |
| #1438 | <h3 className="reg-sub-heading">Sectors / Focus Areas</h3> |
| #1439 | <FormField label="Select sectors your organisation works in"> |
| #1440 | <div className="reg-chips"> |
| #1441 | {SECTOR_OPTIONS.map((sector) => ( |
| #1442 | <button |
| #1443 | key={sector} |
| #1444 | type="button" |
| #1445 | className={`reg-chip ${form.sectors.includes(sector) ? "reg-chip-active" : ""}`} |
| #1446 | onClick={() => toggleSector(sector)} |
| #1447 | > |
| #1448 | {form.sectors.includes(sector) && <span className="reg-chip-check">✓</span>} |
| #1449 | {sector} |
| #1450 | </button> |
| #1451 | ))} |
| #1452 | </div> |
| #1453 | </FormField> |
| #1454 | |
| #1455 | {/* Contact Person */} |
| #1456 | <h3 className="reg-sub-heading">Primary Contact Person</h3> |
| #1457 | <FormField label="Contact Name" required> |
| #1458 | <input type="text" className="reg-input" value={form.contactName} onChange={(e) => setField("contactName", e.target.value)} /> |
| #1459 | </FormField> |
| #1460 | <div className="reg-grid-2"> |
| #1461 | <FormField label="Contact Email" required> |
| #1462 | <input type="email" className="reg-input" value={form.contactEmail} onChange={(e) => setField("contactEmail", e.target.value)} /> |
| #1463 | </FormField> |
| #1464 | <FormField label="Contact Phone"> |
| #1465 | <input type="text" className="reg-input" value={form.contactPhone} onChange={(e) => setField("contactPhone", e.target.value)} /> |
| #1466 | </FormField> |
| #1467 | </div> |
| #1468 | |
| #1469 | <div className="reg-form-actions"> |
| #1470 | <button type="button" className="btn btn-outline" onClick={onClose}>Cancel</button> |
| #1471 | <button type="submit" className="btn btn-primary">Save Changes</button> |
| #1472 | </div> |
| #1473 | </form> |
| #1474 | </div> |
| #1475 | </div> |
| #1476 | ); |
| #1477 | } |
| #1478 | |
| #1479 | // ---- Edit Opportunity Modal ---- |
| #1480 | |
| #1481 | function EditOpportunityModal({ opportunity, onClose, onSave }: { opportunity: PostedOpportunity; onClose: () => void; onSave: (o: PostedOpportunity) => void }) { |
| #1482 | const [form, setForm] = useState<PostedOpportunity>({ ...opportunity }); |
| #1483 | const [saving, setSaving] = useState(false); |
| #1484 | |
| #1485 | const setField = <K extends keyof PostedOpportunity>(key: K, val: PostedOpportunity[K]) => { |
| #1486 | setForm((prev) => ({ ...prev, [key]: val })); |
| #1487 | }; |
| #1488 | |
| #1489 | const handleSubmit = async (e: FormEvent) => { |
| #1490 | e.preventDefault(); |
| #1491 | setSaving(true); |
| #1492 | |
| #1493 | console.log("Saving slots:", form.slots, "for id:", form.id, typeof form.id); |
| #1494 | |
| #1495 | const { data, error } = await supabase |
| #1496 | .from("opportunities") |
| #1497 | .update({ |
| #1498 | title: form.title, |
| #1499 | type: form.type, |
| #1500 | location: form.location, |
| #1501 | deadline: form.deadline || null, |
| #1502 | slots_available: form.slots, |
| #1503 | status: form.status, |
| #1504 | paid_status: form.paidStatus || null, |
| #1505 | stipend_amount: form.stipendAmount || null, |
| #1506 | responsibilities: form.responsibilities || null, |
| #1507 | learning_outcomes: form.learningOutcomes || null, |
| #1508 | expectations: form.expectations || null, |
| #1509 | benefits: form.benefits || null, |
| #1510 | external_link: form.externalLink || null, |
| #1511 | description: form.description || null, |
| #1512 | duration: form.duration || null, |
| #1513 | }) |
| #1514 | .eq("id", form.id) |
| #1515 | .select(); |
| #1516 | |
| #1517 | console.log("Update result:", { data, error, rowsUpdated: data?.length }); |
| #1518 | |
| #1519 | if (error) { |
| #1520 | console.error("Error updating opportunity:", error); |
| #1521 | setSaving(false); |
| #1522 | return; |
| #1523 | } |
| #1524 | |
| #1525 | if (!data || data.length === 0) { |
| #1526 | console.error("No rows updated - ID might not match. ID:", form.id, "Type:", typeof form.id); |
| #1527 | } else { |
| #1528 | console.log("Opportunity updated successfully:", data); |
| #1529 | } |
| #1530 | onSave(form); |
| #1531 | onClose(); |
| #1532 | }; |
| #1533 | |
| #1534 | return ( |
| #1535 | <div className="dash-modal-overlay" onClick={onClose}> |
| #1536 | <div className="dash-modal" onClick={(e) => e.stopPropagation()}> |
| #1537 | <div className="dash-modal-header"> |
| #1538 | <h2>Edit Opportunity</h2> |
| #1539 | <button type="button" className="dash-modal-close" onClick={onClose}>✕</button> |
| #1540 | </div> |
| #1541 | <form onSubmit={handleSubmit} className="dash-modal-body"> |
| #1542 | <FormField label="Title" required> |
| #1543 | <input type="text" className="reg-input" value={form.title} onChange={(e) => setField("title", e.target.value)} /> |
| #1544 | </FormField> |
| #1545 | <div className="reg-grid-2"> |
| #1546 | <FormField label="Type" required> |
| #1547 | <select className="reg-input" value={form.type} onChange={(e) => setField("type", e.target.value)}> |
| #1548 | {OPPORTUNITY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} |
| #1549 | </select> |
| #1550 | </FormField> |
| #1551 | <FormField label="Location"> |
| #1552 | <input type="text" className="reg-input" value={form.location} onChange={(e) => setField("location", e.target.value)} /> |
| #1553 | </FormField> |
| #1554 | <FormField label="Duration"> |
| #1555 | <input type="text" className="reg-input" value={form.duration || ""} onChange={(e) => setField("duration", e.target.value)} placeholder="e.g. 3 months" /> |
| #1556 | </FormField> |
| #1557 | <FormField label="Deadline"> |
| #1558 | <input type="date" className="reg-input" value={form.deadline} onChange={(e) => setField("deadline", e.target.value)} /> |
| #1559 | </FormField> |
| #1560 | <FormField label="Slots"> |
| #1561 | <input type="number" min="0" className="reg-input" value={form.slots} onChange={(e) => setField("slots", Math.max(0, Number(e.target.value)))} /> |
| #1562 | </FormField> |
| #1563 | <FormField label="Status"> |
| #1564 | <select className="reg-input" value={form.status} onChange={(e) => setField("status", e.target.value as PostedOpportunity["status"])}> |
| #1565 | <option value="Active">Active</option> |
| #1566 | <option value="Closed">Closed</option> |
| #1567 | <option value="Draft">Draft</option> |
| #1568 | </select> |
| #1569 | </FormField> |
| #1570 | <FormField label="Payment Status"> |
| #1571 | <select className="reg-input" value={form.paidStatus || ""} onChange={(e) => setField("paidStatus", e.target.value)}> |
| #1572 | <option value="">Select...</option> |
| #1573 | <option value="Paid">Paid</option> |
| #1574 | <option value="Unpaid">Unpaid</option> |
| #1575 | <option value="Stipend">Stipend</option> |
| #1576 | </select> |
| #1577 | </FormField> |
| #1578 | <FormField label="Stipend/Amount"> |
| #1579 | <input type="text" className="reg-input" value={form.stipendAmount || ""} onChange={(e) => setField("stipendAmount", e.target.value)} placeholder="e.g. 500,000 TZS/month" /> |
| #1580 | </FormField> |
| #1581 | </div> |
| #1582 | <FormField label="External Apply Link"> |
| #1583 | <input type="url" className="reg-input" value={form.externalLink || ""} onChange={(e) => setField("externalLink", e.target.value)} placeholder="https://..." /> |
| #1584 | </FormField> |
| #1585 | <FormField label="Responsibilities"> |
| #1586 | <textarea className="reg-input" rows={3} value={form.responsibilities || ""} onChange={(e) => setField("responsibilities", e.target.value)} placeholder="What will the intern/volunteer be doing?" /> |
| #1587 | </FormField> |
| #1588 | <FormField label="Learning Outcomes"> |
| #1589 | <textarea className="reg-input" rows={3} value={form.learningOutcomes || ""} onChange={(e) => setField("learningOutcomes", e.target.value)} placeholder="What will they learn?" /> |
| #1590 | </FormField> |
| #1591 | <FormField label="Expectations"> |
| #1592 | <textarea className="reg-input" rows={3} value={form.expectations || ""} onChange={(e) => setField("expectations", e.target.value)} placeholder="What do you expect from applicants?" /> |
| #1593 | </FormField> |
| #1594 | <FormField label="Benefits"> |
| #1595 | <textarea className="reg-input" rows={3} value={form.benefits || ""} onChange={(e) => setField("benefits", e.target.value)} placeholder="e.g. Mentorship, certificate, networking..." /> |
| #1596 | </FormField> |
| #1597 | <FormField label="Description"> |
| #1598 | <textarea className="reg-input" rows={3} value={form.description || ""} onChange={(e) => setField("description", e.target.value)} placeholder="General description..." /> |
| #1599 | </FormField> |
| #1600 | <div className="reg-form-actions"> |
| #1601 | <button type="button" className="btn btn-outline" onClick={onClose}>Cancel</button> |
| #1602 | <button type="submit" className="btn btn-primary" disabled={saving}>{saving ? "Saving..." : "Save Changes"}</button> |
| #1603 | </div> |
| #1604 | </form> |
| #1605 | </div> |
| #1606 | </div> |
| #1607 | ); |
| #1608 | } |
| #1609 | |
| #1610 | // ---- Reuse from registration ---- |
| #1611 | function FormField({ label, required, children, hint }: { label: string; required?: boolean; children: React.ReactNode; hint?: string }) { |
| #1612 | return ( |
| #1613 | <div className="reg-field"> |
| #1614 | <label className="reg-label">{label}{required && <span className="reg-required">*</span>}</label> |
| #1615 | {children} |
| #1616 | {hint && <span className="reg-hint">{hint}</span>} |
| #1617 | </div> |
| #1618 | ); |
| #1619 | } |
| #1620 | |
| #1621 | function TextInput({ value, onChange, placeholder, type = "text" }: { value: string; onChange: (v: string) => void; placeholder?: string; type?: string }) { |
| #1622 | return ( |
| #1623 | <input type={type} className="reg-input" value={value} onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={placeholder} /> |
| #1624 | ); |
| #1625 | } |
| #1626 |