diff --git a/src/app/candidate/edit/page.tsx b/src/app/candidate/edit/page.tsx new file mode 100644 index 0000000..ea8634b --- /dev/null +++ b/src/app/candidate/edit/page.tsx @@ -0,0 +1,63 @@ +import { requireRoleCapability } from "@/modules/auth/session"; +import { CandidateEditForm } from "@/modules/candidates/CandidateEditForm"; +import { getCountryOptions, getUniversityOptions, getBankOptions } from "@/modules/candidates/actions"; +import { getCandidateDetail } from "@/modules/workspace/data"; +import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; + +export const dynamic = "force-dynamic"; + +export default async function CandidateEditPage() { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const [data, countries, universities, banks] = await Promise.all([ + getCandidateDetail(Number(session.id), "/candidate/invitations"), + getCountryOptions(), + getUniversityOptions(), + getBankOptions(), + ]); + const c = data.candidate; + + return ( + + ({ id: Number(s.id), title: s.title }))} + experiences={data.experiences.map((e) => ({ + id: Number(e.id), + title: e.title, + subtitle: e.subtitle, + }))} + /> + + ); +} diff --git a/src/app/candidate/invitations/[id]/page.tsx b/src/app/candidate/invitations/[id]/page.tsx index 95b7942..8686b61 100644 --- a/src/app/candidate/invitations/[id]/page.tsx +++ b/src/app/candidate/invitations/[id]/page.tsx @@ -4,6 +4,7 @@ import { CompactList, FactPanel } from "@/modules/workspace/DetailPanels"; import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; import { getCandidateInvitationDetail } from "@/modules/workspace/data"; import { formatDate } from "@/modules/workspace/format"; +import { InvitationRespondForm } from "@/modules/candidates/InvitationRespondForm"; export const dynamic = "force-dynamic"; @@ -16,26 +17,34 @@ export default async function CandidateInvitationDetailPage({ params }: { params notFound(); } + const inv = data.invitation; + return ( + + ); } diff --git a/src/app/candidate/page.tsx b/src/app/candidate/page.tsx index dc37902..6ff1540 100644 --- a/src/app/candidate/page.tsx +++ b/src/app/candidate/page.tsx @@ -19,8 +19,10 @@ export default async function CandidatePage() { Boolean(action))} /> diff --git a/src/app/candidate/payments/page.tsx b/src/app/candidate/payments/page.tsx new file mode 100644 index 0000000..a5c9936 --- /dev/null +++ b/src/app/candidate/payments/page.tsx @@ -0,0 +1,32 @@ +import { requireRoleCapability } from "@/modules/auth/session"; +import { DataTable } from "@/modules/workspace/DataTable"; +import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; +import { getCandidateTransferRows } from "@/modules/workspace/data"; + +export const dynamic = "force-dynamic"; + +export default async function CandidatePaymentsPage() { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const rows = await getCandidateTransferRows(Number(session.id)); + + return ( + + {row.company} }, + { key: "period", label: "Period", render: (row) => row.period }, + { key: "hours", label: "Hours", render: (row) => row.hours }, + { key: "candidateTotal", label: "Your Total", render: (row) => row.candidateTotal }, + { key: "companyTotal", label: "Company Total", render: (row) => row.companyTotal }, + { key: "cost", label: "Transfer Cost", render: (row) => row.cost }, + { key: "paid", label: "Paid", render: (row) => row.paid }, + { key: "paymentDate", label: "Payment Date", render: (row) => row.paymentDate }, + { key: "updated", label: "Updated", render: (row) => row.updated }, + ]} + /> + + ); +} diff --git a/src/app/candidate/work-logs/[id]/page.tsx b/src/app/candidate/work-logs/[id]/page.tsx index adcdac2..c244b5d 100644 --- a/src/app/candidate/work-logs/[id]/page.tsx +++ b/src/app/candidate/work-logs/[id]/page.tsx @@ -4,6 +4,7 @@ import { CompactList, FactPanel } from "@/modules/workspace/DetailPanels"; import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; import { getCandidateWorkLogDetail } from "@/modules/workspace/data"; import { formatDate } from "@/modules/workspace/format"; +import { WorkLogAppealForm } from "@/modules/candidates/WorkLogAppealForm"; export const dynamic = "force-dynamic"; @@ -33,9 +34,11 @@ export default async function CandidateWorkLogDetailPage({ params }: { params: P { label: "Store Location", value: data.workLog.store?.store_location }, { label: "Start", value: formatDate(data.workLog.start_time) }, { label: "End", value: formatDate(data.workLog.end_time) }, - { label: "Note", value: data.workLog.note } + { label: "Note", value: data.workLog.note }, ]} /> + + ); } diff --git a/src/app/styles.css b/src/app/styles.css index 070cdb9..79131c6 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1,3 +1,28 @@ +@import "tailwindcss"; + +@theme { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted-shadcn); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius: var(--radius); +} + :root { color-scheme: light; --ink: #182230; @@ -783,6 +808,112 @@ body { padding: 14px; } +.workTabs { + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; + padding: 0 0 2px; +} + +.workTabs::-webkit-scrollbar { + display: none; +} + +.workTab { + display: inline-flex; + align-items: center; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-soft); + overflow: hidden; + flex-shrink: 0; +} + +.workTab.active { + border-color: var(--blue); + background: #eef5ff; +} + +.workTab button { + min-height: 32px; + display: inline-flex; + align-items: center; + border: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; +} + +.workTab.active button { + color: var(--blue); +} + +.workTabClose { + min-width: 24px; + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-left: 1px solid var(--line); + background: none; + color: var(--muted); + cursor: pointer; + padding: 0; +} + +.workTabClose:hover { + color: var(--rose); + background: rgba(180, 35, 87, 0.08); +} + +.workTabsClear { + min-height: 32px; + display: inline-flex; + align-items: center; + border: 1px solid transparent; + border-radius: 6px; + background: none; + color: var(--faint); + font: inherit; + font-size: 11px; + font-weight: 600; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.workTabsClear:hover { + color: var(--rose); + border-color: var(--line); +} + +[data-theme="dark"] .workTab { + border-color: var(--line); + background: var(--surface-soft); +} + +[data-theme="dark"] .workTab.active { + border-color: var(--blue); + background: rgba(138, 191, 255, 0.12); +} + +[data-theme="dark"] .workTab.active button { + color: var(--blue); +} + +[data-theme="dark"] .workTabClose { + border-color: var(--line); +} + .topbar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 300px); @@ -1237,6 +1368,104 @@ h2 { margin: 0; } +.formNotice { + color: var(--muted-foreground); + margin: 0; +} + +.candidateEditLayout { + display: grid; + gap: 1.5rem; +} + +.candidateEditForm { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.candidateEditForm h2 { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + padding-top: 0.5rem; +} + +.candidateEditForm label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.candidateEditForm label span { + font-size: 0.875rem; + font-weight: 500; + color: var(--muted-foreground); +} + +.candidateEditForm input, +.candidateEditForm textarea, +.candidateEditForm select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + font-size: 0.9375rem; + background: var(--background); + color: var(--foreground); +} + +.candidateEditForm input:focus, +.candidateEditForm textarea:focus, +.candidateEditForm select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--ring); +} + +.formActions { + display: flex; + gap: 0.5rem; + padding-top: 0.5rem; +} + +.formActions button { + padding: 0.5rem 1.25rem; + border: none; + border-radius: 0.5rem; + font-size: 0.9375rem; + font-weight: 500; + background: var(--primary); + color: var(--primary-foreground); + cursor: pointer; +} + +.formActions button:disabled { + cursor: wait; + opacity: 0.7; +} + +.formActions button.acceptButton { + background: var(--primary); + color: var(--primary-foreground); +} + +.formActions button.rejectButton { + background: var(--destructive); + color: var(--destructive-foreground); +} + +.documentUploadField { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem; +} + +.documentUploadField legend { + font-size: 0.875rem; + font-weight: 500; + color: var(--muted-foreground); +} + .unifiedLoginShell { align-items: start; } @@ -8964,6 +9193,54 @@ kbd { } } +@media (max-width: 480px) { + .mobileTabBar { + right: 6px; + bottom: 6px; + left: 6px; + padding: 6px; + gap: 4px; + } + + .mobileTabBar a { + min-height: 38px; + padding: 0 10px; + font-size: 12px; + } + + .workspaceStage { + padding: 8px 8px 82px; + } + + .shell .workspaceStage { + width: auto; + } + + .workTabs { + gap: 2px; + padding: 0 0 4px; + } + + .workTab button { + min-height: 28px; + font-size: 11px; + padding: 0 8px; + } + + .commandOverlay .commandMenu { + width: calc(100vw - 16px); + max-width: 360px; + } + + .commandInputWrap input { + font-size: 14px; + } + + body { + background-size: 28px 28px; + } +} + .requestOS { display: grid; gap: 18px; diff --git a/src/modules/candidates/CandidateEditForm.tsx b/src/modules/candidates/CandidateEditForm.tsx new file mode 100644 index 0000000..92830fb --- /dev/null +++ b/src/modules/candidates/CandidateEditForm.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useActionState } from "react"; +import { + updateCandidateProfile, + uploadDocument, + addCandidateSkill, + removeCandidateSkill, + addCandidateExperience, + removeCandidateExperience, +} from "@/modules/candidates/actions"; + +type Option = { id: number; label: string }; + +type Skill = { id: number; title: string }; +type Experience = { id: number; title: string; subtitle: string }; + +type Props = { + candidate: { + name: string; + nameAr: string; + email: string; + phone: string; + objective: string; + intro: string; + civilId: string; + profileUrl: string; + birthDate: string; + address: string; + countryId: number | null; + universityId: number | null; + bankId: number | null; + bankAccountName: string; + iban: string; + personalPhoto: string | null; + resume: string | null; + video: string | null; + civilPhotoFront: string | null; + civilPhotoBack: string | null; + }; + countries: Option[]; + universities: Option[]; + banks: Option[]; + skills: Skill[]; + experiences: Experience[]; +}; + +export function CandidateEditForm({ candidate, countries, universities, banks, skills, experiences }: Props) { + const [profileState, profileAction, profilePending] = useActionState(updateCandidateProfile, { + error: "", + }); + const [uploadState, uploadAction, uploadPending] = useActionState(uploadDocument, { + error: "", + }); + const [, addSkillAction, addSkillPending] = useActionState(addCandidateSkill, { error: "" }); + const [, removeSkillAction, removeSkillPending] = useActionState(removeCandidateSkill, { error: "" }); + const [, addExpAction, addExpPending] = useActionState(addCandidateExperience, { error: "" }); + const [, removeExpAction, removeExpPending] = useActionState(removeCandidateExperience, { error: "" }); + + return ( + + + Personal info + {profileState.error ? {profileState.error} : null} + + + Name (English) + + + + + Name (Arabic) + + + + + Email + + + + + Phone + + + + + Birth date + + + + Location & education + + + Country / Nationality + + — Not set — + {countries.map((c) => ( + + {c.label} + + ))} + + + + + University + + — Not set — + {universities.map((u) => ( + + {u.label} + + ))} + + + + + Address + + + + Bank info + + + Bank + + — Not set — + {banks.map((b) => ( + + {b.label} + + ))} + + + + + Account holder name + + + + + IBAN + + + + Profile details + + + Civil ID + + + + + Objective / Headline + + + + + Profile URL + + + + + About / Intro + + + + + + {profilePending ? "Saving..." : "Save profile"} + + + + + + Documents + {uploadState.error ? {uploadState.error} : null} + + + + + + + + + + + + + + {uploadPending ? "Uploading..." : "Upload document"} + + + + + + Skills + + {skills.length ? ( + + {skills.map((s) => ( + + {s.title} + + + + Remove + + + + ))} + + ) : ( + No skills added yet. + )} + + + Add skill + + + + + + {addSkillPending ? "Adding..." : "Add skill"} + + + + + + Work experience + + {experiences.length ? ( + + {experiences.map((e) => ( + + {e.title}{e.subtitle ? ` at ${e.subtitle}` : ""} + + + + Remove + + + + ))} + + ) : ( + No work experience added yet. + )} + + + Job title / Role + + + + + Employer / Company + + + + + + Start year + + + + End year + + + + + + + {addExpPending ? "Adding..." : "Add experience"} + + + + + ); +} + +function DocumentUpload({ + label, + type, + current, +}: { + label: string; + type: string; + current: string | null; +}) { + return ( + + {label} + + + {current ? ( + + Current:{" "} + + {current.split("/").pop()} + + + ) : ( + No file uploaded yet. + )} + + ); +} + +function acceptFor(type: string): string { + switch (type) { + case "photo": + case "civilFront": + case "civilBack": + return "image/*"; + case "cv": + return ".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "video": + return "video/*"; + default: + return "*/*"; + } +} diff --git a/src/modules/candidates/CandidateProfile.tsx b/src/modules/candidates/CandidateProfile.tsx index d915116..11cc37f 100644 --- a/src/modules/candidates/CandidateProfile.tsx +++ b/src/modules/candidates/CandidateProfile.tsx @@ -83,6 +83,12 @@ export function CandidateProfile({ ))} + {readiness.missing?.length ? ( + + Missing fields + {readiness.missing.join(" · ")} + + ) : null} @@ -203,21 +209,31 @@ function RowContent({ row }: { row: { title: string; subtitle: string; meta?: st } function buildReadiness(detail: CandidateDetailData) { - const candidate = detail.candidate; + const c = detail.candidate; const items = [ - { label: "Approved", done: Boolean(candidate && candidate.approved !== 0) }, - { label: "Active status", done: candidate?.candidate_status === 10 }, - { label: "Profile complete", done: Boolean(candidate && !candidate.is_incomplete_profile) }, - { label: "Civil ID clear", done: Boolean(candidate && !candidate.candidate_civil_need_verification) }, - { label: "Contact verified", done: Boolean(candidate?.candidate_email_verification || candidate?.candidate_phone) }, - { label: "Skills imported", done: detail.skills.length > 0 }, - { label: "Work context", done: detail.histories.length > 0 || detail.invitations.length > 0 }, - { label: "Document trail", done: detail.idCards.length > 0 || detail.links.length > 0 || Boolean(candidate?.candidate_resume) } + { label: "Name", done: Boolean(c?.candidate_name), field: "Name (English)" }, + { label: "Email", done: Boolean(c?.candidate_email), field: "Email address" }, + { label: "Phone", done: Boolean(c?.candidate_phone), field: "Phone number" }, + { label: "Country", done: Boolean(c?.country_id), field: "Country / Nationality" }, + { label: "University", done: Boolean(c?.university_id), field: "University" }, + { label: "Objective", done: Boolean(c?.candidate_objective), field: "Objective / Headline" }, + { label: "Civil ID number", done: Boolean(c?.candidate_civil_id), field: "Civil ID number" }, + { label: "Civil ID photos", done: Boolean(c?.candidate_civil_photo_front || c?.candidate_civil_photo_back), field: "Civil ID photos (front/back)" }, + { label: "Profile photo", done: Boolean(c?.candidate_personal_photo), field: "Profile photo upload" }, + { label: "CV / Resume", done: Boolean(c?.candidate_resume), field: "CV / Resume upload" }, + { label: "Bank info", done: Boolean(c?.bank_id || c?.candidate_iban), field: "Bank name or IBAN" }, + { label: "Skills", done: detail.skills.length > 0, field: "At least one skill tag" }, + { label: "Experience", done: detail.experiences.length > 0, field: "Work experience entries" }, + { label: "Approved", done: Boolean(c && c.approved !== 0), field: "Staff approval" }, ]; const done = items.filter((item) => item.done).length; const score = Math.round((done / items.length) * 100); - const summary = score >= 85 ? "Ready to present" : score >= 60 ? "Usable with cleanup" : "Needs staff attention"; - return { items, score, summary }; + const summary = + score >= 85 ? "Ready to present" + : score >= 60 ? "Usable with cleanup — fill in the open fields below" + : "Needs attention — complete the missing fields to improve your profile visibility"; + const missing = items.filter((item) => !item.done).map((item) => item.field ?? item.label); + return { items, missing, score, summary }; } function buildTimeline(detail: CandidateDetailData) { diff --git a/src/modules/candidates/InvitationRespondForm.tsx b/src/modules/candidates/InvitationRespondForm.tsx new file mode 100644 index 0000000..85e52ba --- /dev/null +++ b/src/modules/candidates/InvitationRespondForm.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useActionState } from "react"; +import { respondToInvitation } from "@/modules/candidates/actions"; + +export function InvitationRespondForm({ + invitationUuid, + currentStatus, +}: { + invitationUuid: string; + currentStatus: number; +}) { + const [state, action, pending] = useActionState(respondToInvitation, { error: "" }); + + if (currentStatus === 1 || currentStatus === 2) { + return ( + + Response + + You have already {currentStatus === 1 ? "accepted" : "rejected"} this invitation. + + + ); + } + + return ( + + Respond to Invitation + {state.error ? {state.error} : null} + + + + + + {pending ? "Sending..." : "Accept invitation"} + + + {pending ? "Sending..." : "Reject invitation"} + + + + ); +} diff --git a/src/modules/candidates/WorkLogAppealForm.tsx b/src/modules/candidates/WorkLogAppealForm.tsx new file mode 100644 index 0000000..1932f74 --- /dev/null +++ b/src/modules/candidates/WorkLogAppealForm.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useActionState } from "react"; +import { appealWorkLog } from "@/modules/candidates/actions"; + +export function WorkLogAppealForm({ workLogUuid }: { workLogUuid: string }) { + const [state, action, pending] = useActionState(appealWorkLog, { error: "" }); + + return ( + + Appeal this Work Log + {state.error ? {state.error} : null} + + + + + Reason for appeal + + + + + + {pending ? "Submitting..." : "Submit appeal"} + + + + ); +} diff --git a/src/modules/candidates/actions.ts b/src/modules/candidates/actions.ts new file mode 100644 index 0000000..61a225e --- /dev/null +++ b/src/modules/candidates/actions.ts @@ -0,0 +1,432 @@ +"use server"; + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireRoleCapability } from "@/modules/auth/session"; + +// --------------------------------------------------------------------------- +// Profile edit +// --------------------------------------------------------------------------- + +export async function updateCandidateProfile(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const name = formData.get("name"); + const nameAr = formData.get("nameAr"); + const email = formData.get("email"); + const phone = formData.get("phone"); + const objective = formData.get("objective"); + const intro = formData.get("intro"); + const civilId = formData.get("civilId"); + const profileUrl = formData.get("profileUrl"); + const countryId = formData.get("countryId"); + const universityId = formData.get("universityId"); + const bankId = formData.get("bankId"); + const bankAccountName = formData.get("bankAccountName"); + const iban = formData.get("iban"); + const birthDate = formData.get("birthDate"); + const address = formData.get("address"); + + if (typeof name !== "string" || !name.trim()) { + return { error: "Name is required." }; + } + + const stringField = (value: unknown) => + typeof value === "string" ? value.trim() : undefined; + + const nullableStringField = (value: unknown) => + typeof value === "string" ? (value.trim() || undefined) : undefined; + + const nullableIntField = (value: unknown) => { + if (typeof value !== "string" || !value) return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; + }; + + await prisma.candidate.update({ + where: { candidate_id: candidateId }, + data: { + candidate_name: name.trim(), + candidate_name_ar: nullableStringField(nameAr), + candidate_email: stringField(email), + candidate_phone: nullableStringField(phone), + candidate_objective: nullableStringField(objective), + candidate_intro: nullableStringField(intro), + candidate_civil_id: nullableStringField(civilId), + profile_url: nullableStringField(profileUrl), + candidate_address_line1: nullableStringField(address), + country_id: nullableIntField(countryId), + university_id: nullableIntField(universityId), + bank_id: nullableIntField(bankId), + bank_account_name: nullableStringField(bankAccountName), + candidate_iban: nullableStringField(iban), + candidate_birth_date: + typeof birthDate === "string" && birthDate ? new Date(birthDate) : undefined, + }, + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + redirect("/candidate"); +} + +// --------------------------------------------------------------------------- +// Document upload +// --------------------------------------------------------------------------- + +const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads", "candidates"); + +const ALLOWED_TYPES: Record = { + photo: { + mime: ["image/jpeg", "image/png", "image/webp", "image/gif"], + ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"], + maxSize: 5 * 1024 * 1024, // 5 MB + }, + cv: { + mime: ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + ext: [".pdf", ".doc", ".docx"], + maxSize: 10 * 1024 * 1024, // 10 MB + }, + video: { + mime: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], + ext: [".mp4", ".webm", ".ogv", ".mov"], + maxSize: 50 * 1024 * 1024, // 50 MB + }, + civilFront: { + mime: ["image/jpeg", "image/png", "image/webp", "image/gif"], + ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"], + maxSize: 5 * 1024 * 1024, + }, + civilBack: { + mime: ["image/jpeg", "image/png", "image/webp", "image/gif"], + ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"], + maxSize: 5 * 1024 * 1024, + }, +}; + +async function saveUpload(candidateId: number, field: string, file: File, typeConfig: typeof ALLOWED_TYPES[string]): Promise { + const ext = path.extname(file.name).toLowerCase(); + if (!typeConfig.ext.includes(ext)) { + throw new Error(`File type "${ext}" is not allowed for this document type.`); + } + + if (file.size > typeConfig.maxSize) { + throw new Error(`File is too large. Maximum size is ${typeConfig.maxSize / 1024 / 1024} MB.`); + } + + const dir = path.join(UPLOAD_DIR, String(candidateId)); + await fs.mkdir(dir, { recursive: true }); + + const filename = `${field}_${crypto.randomUUID()}${ext}`; + const filepath = path.join(dir, filename); + + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(filepath, buffer); + + return `/uploads/candidates/${candidateId}/${filename}`; +} + +export async function uploadDocument(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const type = String(formData.get("type") ?? ""); + const file = formData.get("file"); + + if (!(file instanceof File) || file.size === 0) { + return { error: "Please select a file to upload." }; + } + + const allowed = ["photo", "cv", "video", "civilFront", "civilBack"]; + if (!allowed.includes(type)) { + return { error: "Invalid document type." }; + } + + const typeConfig = ALLOWED_TYPES[type]; + if (type === "cv" && file.type && !typeConfig.mime.includes(file.type)) { + return { error: `Invalid file type for CV. Accepted: PDF, DOC, DOCX.` }; + } + if (type !== "cv" && type !== "video" && file.type && !typeConfig.mime.includes(file.type)) { + return { error: `Invalid file type for ${type}. Accepted image formats.` }; + } + + if (file.size > typeConfig.maxSize) { + return { error: `File is too large. Maximum size is ${typeConfig.maxSize / 1024 / 1024} MB.` }; + } + + try { + const path_ = await saveUpload(candidateId, type, file, typeConfig); + + const fieldMap: Record> = { + photo: { candidate_personal_photo: path_ }, + cv: { candidate_resume: path_ }, + video: { candidate_video: path_ }, + civilFront: { candidate_civil_photo_front: path_ }, + civilBack: { candidate_civil_photo_back: path_ }, + }; + + await prisma.candidate.update({ + where: { candidate_id: candidateId }, + data: fieldMap[type], + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + return { error: "" }; + } catch (e) { + return { error: e instanceof Error ? e.message : "Upload failed." }; + } +} + +// --------------------------------------------------------------------------- +// Invitation accept / reject +// --------------------------------------------------------------------------- + +const INVITATION_STATUS_ACCEPTED = 1; +const INVITATION_STATUS_REJECTED = 2; + +export async function respondToInvitation(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const invitationUuid = String(formData.get("invitationUuid") ?? ""); + const action = String(formData.get("action") ?? ""); // "accept" | "reject" + + if (!invitationUuid) { + return { error: "Missing invitation identifier." }; + } + + if (action !== "accept" && action !== "reject") { + return { error: "Invalid action." }; + } + + const invitation = await prisma.invitation.findFirst({ + where: { invitation_uuid: invitationUuid, candidate_id: candidateId }, + select: { invitation_uuid: true }, + }); + + if (!invitation) { + return { error: "Invitation not found." }; + } + + const newStatus = action === "accept" ? INVITATION_STATUS_ACCEPTED : INVITATION_STATUS_REJECTED; + + await prisma.invitation.update({ + where: { invitation_uuid: invitationUuid }, + data: { + invitation_status: newStatus, + invitation_app_seen_at: new Date(), + invitation_updated_at: new Date(), + }, + }); + + revalidatePath("/candidate/invitations"); + revalidatePath(`/candidate/invitations/${invitationUuid}`); + redirect(`/candidate/invitations/${invitationUuid}`); +} + +// --------------------------------------------------------------------------- +// Work-log appeal +// --------------------------------------------------------------------------- + +export async function appealWorkLog(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const workLogUuid = String(formData.get("workLogUuid") ?? ""); + const reason = String(formData.get("reason") ?? "").trim(); + + if (!workLogUuid) { + return { error: "Missing work-log identifier." }; + } + + if (!reason) { + return { error: "Please provide a reason for the appeal." }; + } + + const workLog = await prisma.candidate_working_hour.findFirst({ + where: { candidate_working_hour_uuid: workLogUuid, candidate_id: candidateId }, + select: { candidate_working_hour_uuid: true }, + }); + + if (!workLog) { + return { error: "Work log not found." }; + } + + const appealUuid = `appeal_${crypto.randomUUID()}`; + const now = new Date(); + + await prisma.$transaction([ + prisma.candidate_working_hour_appeal.create({ + data: { + appeal_uuid: appealUuid, + candidate_working_hour_uuid: workLogUuid, + candidate_id: candidateId, + reason, + status: 0, + created_at: now, + updated_at: now, + }, + }), + prisma.candidate_working_hour.update({ + where: { candidate_working_hour_uuid: workLogUuid }, + data: { appeal_uuid: appealUuid }, + }), + ]); + + revalidatePath("/candidate/work-logs"); + revalidatePath(`/candidate/work-logs/${workLogUuid}`); + redirect(`/candidate/work-logs/${workLogUuid}`); +} + +// --------------------------------------------------------------------------- +// Lookup helpers (not server actions — plain async functions for pages) +// --------------------------------------------------------------------------- + +export async function getCountryOptions() { + const rows = await prisma.country.findMany({ + orderBy: { country_name_en: "asc" }, + select: { country_id: true, country_name_en: true, country_nationality_name_en: true }, + take: 250, + }); + return rows.map((r) => ({ + id: r.country_id, + label: `${r.country_name_en}${r.country_nationality_name_en && r.country_nationality_name_en !== r.country_name_en ? ` (${r.country_nationality_name_en})` : ""}`, + })); +} + +export async function getUniversityOptions() { + const rows = await prisma.university.findMany({ + where: { deleted: 0 }, + orderBy: { university_name_en: "asc" }, + select: { university_id: true, university_name_en: true }, + take: 250, + }); + return rows.map((r) => ({ + id: r.university_id, + label: r.university_name_en ?? `University #${r.university_id}`, + })); +} + +export async function getBankOptions() { + const rows = await prisma.bank.findMany({ + where: { deleted: 0 }, + orderBy: { bank_name: "asc" }, + select: { bank_id: true, bank_name: true }, + take: 100, + }); + return rows.map((r) => ({ + id: r.bank_id, + label: r.bank_name ?? `Bank #${r.bank_id}`, + })); +} + +// --------------------------------------------------------------------------- +// Skills CRUD +// --------------------------------------------------------------------------- + +export async function addCandidateSkill(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const skill = String(formData.get("skill") ?? "").trim(); + if (!skill) return { error: "Skill name is required." }; + + await prisma.candidate_skill.create({ + data: { + candidate_id: candidateId, + skill, + candidate_skill_created_at: new Date(), + deleted: 0, + }, + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + return { error: "" }; +} + +export async function removeCandidateSkill(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const skillId = Number(formData.get("skillId")); + if (!Number.isInteger(skillId) || skillId <= 0) return { error: "Invalid skill identifier." }; + + const row = await prisma.candidate_skill.findFirst({ + where: { candidate_skill_id: skillId, candidate_id: candidateId, deleted: 0 }, + select: { candidate_skill_id: true }, + }); + + if (!row) return { error: "Skill not found." }; + + await prisma.candidate_skill.update({ + where: { candidate_skill_id: skillId }, + data: { deleted: 1 }, + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + return { error: "" }; +} + +// --------------------------------------------------------------------------- +// Work experience CRUD +// --------------------------------------------------------------------------- + +export async function addCandidateExperience(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const experience = String(formData.get("experience") ?? "").trim(); + const employer = String(formData.get("employer") ?? "").trim(); + const startYear = Number(formData.get("startYear")); + const endYear = Number(formData.get("endYear")); + + if (!experience) return { error: "Job title / experience is required." }; + + await prisma.candidate_experience.create({ + data: { + candidate_id: candidateId, + experience, + employer: employer || undefined, + start_year: Number.isFinite(startYear) ? startYear : undefined, + end_year: Number.isFinite(endYear) ? endYear : undefined, + candidate_experience_created_at: new Date(), + deleted: 0, + }, + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + return { error: "" }; +} + +export async function removeCandidateExperience(_prevState: { error: string }, formData: FormData) { + const session = await requireRoleCapability("candidate", "candidate.read.own"); + const candidateId = Number(session.id); + + const experienceId = Number(formData.get("experienceId")); + if (!Number.isInteger(experienceId) || experienceId <= 0) return { error: "Invalid experience identifier." }; + + const row = await prisma.candidate_experience.findFirst({ + where: { candidate_experience_id: experienceId, candidate_id: candidateId, deleted: 0 }, + select: { candidate_experience_id: true }, + }); + + if (!row) return { error: "Experience record not found." }; + + await prisma.candidate_experience.update({ + where: { candidate_experience_id: experienceId }, + data: { deleted: 1 }, + }); + + revalidatePath("/candidate"); + revalidatePath("/candidate/edit"); + return { error: "" }; +} diff --git a/src/modules/requests/application-actions.ts b/src/modules/requests/application-actions.ts new file mode 100644 index 0000000..dac748f --- /dev/null +++ b/src/modules/requests/application-actions.ts @@ -0,0 +1,70 @@ +"use server"; + +import crypto from "node:crypto"; +import type { Route } from "next"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireCapability } from "@/modules/auth/session"; + +export async function transitionApplicationAction(formData: FormData) { + const session = await requireCapability("request.suggest"); + + const applicationUuid = String(formData.get("application_uuid") ?? "").trim(); + const requestUuid = String(formData.get("request_uuid") ?? "").trim(); + const newStatus = Number(formData.get("status")); + const note = String(formData.get("note") ?? "").trim() || null; + const basePath = session.role === "admin" ? "/admin/requests" : "/staff/requests"; + const detailPath = `${basePath}/${requestUuid}`; + + if (!applicationUuid || !requestUuid || !Number.isInteger(newStatus)) { + redirect(`${detailPath}?notice=missing-fields` as Route); + } + + const application = await prisma.request_application.findFirst({ + where: { application_uuid: applicationUuid, request_uuid: requestUuid }, + select: { application_uuid: true, candidate_id: true } + }); + + if (!application) { + redirect(`${detailPath}?notice=not-found` as Route); + } + + const now = new Date(); + const staffId = Number(session.id); + + const operations: any[] = [ + prisma.request_application.update({ + where: { application_uuid: applicationUuid }, + data: { status: newStatus, updated_at: now } + }), + prisma.request.update({ + where: { request_uuid: requestUuid }, + data: { request_updated_datetime: now } + }) + ]; + + if (note) { + operations.push( + prisma.note.create({ + data: { + note_uuid: `note_${crypto.randomUUID()}`, + request_uuid: requestUuid, + candidate_id: application.candidate_id, + note_type: "Application", + note_text: note, + created_by: staffId, + updated_by: staffId, + note_created_datetime: now, + note_updated_datetime: now + } + }) + ); + } + + await prisma.$transaction(operations); + + revalidatePath(detailPath); + revalidatePath(basePath); + redirect(`${detailPath}?notice=application-updated` as Route); +} diff --git a/src/modules/workspace/WorkTabs.tsx b/src/modules/workspace/WorkTabs.tsx new file mode 100644 index 0000000..850e4d8 --- /dev/null +++ b/src/modules/workspace/WorkTabs.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import type { Route } from "next"; +import { X } from "lucide-react"; + +type WorkTab = { + path: string; + label: string; +}; + +const STORAGE_KEY = "studenthub-work-tabs"; +const MAX_TABS = 8; + +function readTabs(): WorkTab[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.slice(0, MAX_TABS); + } catch { + return []; + } +} + +function writeTabs(tabs: WorkTab[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs.slice(0, MAX_TABS))); + } catch { + // localStorage may be full or unavailable + } +} + +function deriveLabel(pathname: string): string | null { + const segments = pathname.split("/").filter(Boolean); + if (segments.length < 2) return null; + + const names: Record = { + candidates: "Candidate", + companies: "Company", + requests: "Request", + transfers: "Transfer", + invitations: "Invitation", + "work-logs": "Work Log", + "id-requests": "ID Request", + }; + + const mod = segments[1]; + const id = segments[2]; + if (!id) return null; + + const moduleName = names[mod] ?? mod; + const displayId = id.length > 12 ? `${id.slice(0, 12)}...` : id; + return `${moduleName} ${displayId}`; +} + +export function useWorkTabs() { + const pathname = usePathname(); + const router = useRouter(); + const [tabs, setTabs] = useState([]); + + // Hydrate from localStorage on mount + useEffect(() => { + setTabs(readTabs()); + }, []); + + // Add current path as a tab when it's a record detail page + useEffect(() => { + const label = deriveLabel(pathname); + if (!label) return; + + setTabs((prev) => { + const existing = prev.findIndex((t) => t.path === pathname); + let next: WorkTab[]; + if (existing >= 0) { + next = [...prev]; + next[existing] = { path: pathname, label }; + } else { + next = [{ path: pathname, label }, ...prev]; + } + next = next.slice(0, MAX_TABS); + writeTabs(next); + return next; + }); + }, [pathname]); + + const closeTab = useCallback( + (path: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.path !== path); + writeTabs(next); + return next; + }); + if (pathname === path) { + const remaining = tabs.filter((t) => t.path !== path); + if (remaining.length > 0) { + router.push(remaining[0].path as Route); + } + } + }, + [pathname, router, tabs] + ); + + const closeAll = useCallback(() => { + setTabs([]); + writeTabs([]); + }, []); + + return { tabs, closeTab, closeAll }; +} + +export type WorkTabState = ReturnType; + +export function WorkTabs({ state }: { state: WorkTabState }) { + const pathname = usePathname(); + const router = useRouter(); + + if (!state.tabs.length) return null; + + return ( + + {state.tabs.map((tab) => { + const active = pathname === tab.path; + return ( + + router.push(tab.path as Route)} + aria-current={active ? "page" : undefined} + > + {tab.label} + + { + e.stopPropagation(); + state.closeTab(tab.path); + }} + > + + + + ); + })} + {state.tabs.length > 1 ? ( + + Clear all + + ) : null} + + ); +} diff --git a/src/modules/workspace/data.ts b/src/modules/workspace/data.ts index 070b953..e774145 100644 --- a/src/modules/workspace/data.ts +++ b/src/modules/workspace/data.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/prisma"; -import { formatDate, formatMoney } from "./format"; +import { formatDate, formatMoney } from "@/modules/workspace/format"; export async function getAdminCandidateRows() { const rows = await prisma.candidate.findMany({ @@ -794,6 +794,14 @@ export async function getCandidateDetail(candidateId: number, requestBasePath = candidate_phone: true, candidate_civil_id: true, candidate_civil_expiry_date: true, + candidate_civil_photo_front: true, + candidate_civil_photo_back: true, + candidate_video: true, + candidate_address_line1: true, + candidate_birth_date: true, + bank_id: true, + bank_account_name: true, + candidate_iban: true, candidate_status: true, approved: true, candidate_hourly_rate: true, @@ -804,7 +812,9 @@ export async function getCandidateDetail(candidateId: number, requestBasePath = profile_url: true, candidate_created_at: true, candidate_updated_at: true, + country_id: true, country: { select: { country_name_en: true } }, + university_id: true, university: { select: { university_name_en: true } }, store: { select: { store_name: true, company: { select: { company_name: true } } } } } @@ -1495,6 +1505,7 @@ export async function getRequestDetail( title: application.candidate?.candidate_name ?? "Unknown candidate", subtitle: application.candidate?.candidate_email ?? "No email", meta: `Status ${application.status ?? 0} · ${formatDate(application.created_at)}`, + status: application.status, href: application.candidate?.candidate_id ? options.candidateHref ? options.candidateHref(application.candidate.candidate_id) @@ -1507,13 +1518,15 @@ export async function getRequestDetail( id: interview.request_interview_uuid, title: interview.candidate?.candidate_name ?? "Interview", subtitle: interview.candidate?.candidate_email ?? "No email", - meta: `Status ${interview.status ?? 0} · ${formatDate(interview.interview_at)}` + meta: `Status ${interview.status ?? 0} · ${formatDate(interview.interview_at)}`, + status: interview.status })), invitations: invitations.map((invitation) => ({ id: invitation.invitation_uuid, title: invitation.candidate?.candidate_name ?? "Invitation", subtitle: invitation.candidate?.candidate_email ?? "No email", - meta: `Status ${invitation.invitation_status ?? 0} · ${formatDate(invitation.invitation_created_at)}` + meta: `Status ${invitation.invitation_status ?? 0} · ${formatDate(invitation.invitation_created_at)}`, + status: invitation.invitation_status })), suggestions: suggestions.map((suggestion) => ({ id: suggestion.suggestion_uuid, @@ -1537,7 +1550,8 @@ export async function getRequestDetail( id: story.story_uuid, title: `Story ${story.story_uuid.slice(0, 12)}`, subtitle: `Status ${story.story_status}`, - meta: formatDate(story.story_last_updated_at) + meta: formatDate(story.story_last_updated_at), + status: story.story_status })) }; } @@ -1865,6 +1879,56 @@ export async function getCandidateWorkLogDetail(candidateId: number, workLogUuid }; } +export async function getCandidateTransferRows(candidateId: number) { + const rows = await prisma.transfer_candidate.findMany({ + where: { candidate_id: candidateId, deleted: 0 }, + orderBy: { tc_updated_at: "desc" }, + take: 80, + select: { + tc_id: true, + transfer_id: true, + candidate_total: true, + company_total: true, + transfer_cost: true, + hours: true, + minutes: true, + paid: true, + currency_code: true, + tc_updated_at: true, + company: { select: { company_name: true } }, + store: { select: { store_name: true } }, + transfer: { + select: { + transfer_status: true, + start_date: true, + end_date: true, + payment_received_on: true, + currency_code: true, + }, + }, + }, + }); + + return rows.map((row) => ({ + id: row.tc_id, + transferId: row.transfer_id, + company: row.company?.company_name ?? row.store?.store_name ?? "No company", + period: row.transfer?.start_date + ? `${formatDate(row.transfer.start_date)} to ${formatDate(row.transfer.end_date)}` + : "No period", + hours: `${row.hours ?? 0}h ${row.minutes ?? 0}m`, + candidateTotal: formatMoney(row.candidate_total, row.currency_code ?? row.transfer?.currency_code ?? "KWD"), + companyTotal: formatMoney(row.company_total, row.currency_code ?? row.transfer?.currency_code ?? "KWD"), + cost: formatMoney(row.transfer_cost, row.currency_code ?? row.transfer?.currency_code ?? "KWD"), + paid: row.paid ? "Paid" : "Unpaid", + transferStatus: `Transfer status ${row.transfer?.transfer_status ?? 0}`, + paymentDate: row.transfer?.payment_received_on + ? formatDate(row.transfer.payment_received_on) + : "Not received", + updated: formatDate(row.tc_updated_at), + })); +} + async function companyIdsForContact(contactUuid: string) { const links = await prisma.company_contact.findMany({ where: { contact_uuid: contactUuid, allow_access: true }, diff --git a/src/modules/workspace/navigation.ts b/src/modules/workspace/navigation.ts index b7fc698..030dd64 100644 --- a/src/modules/workspace/navigation.ts +++ b/src/modules/workspace/navigation.ts @@ -32,7 +32,8 @@ export function navForRole(role: Role): NavItem[] { { label: "App", href: "/app" }, { label: "Overview", href: "/candidate" }, { label: "Invitations", href: "/candidate/invitations" }, - { label: "Work Logs", href: "/candidate/work-logs" } + { label: "Work Logs", href: "/candidate/work-logs" }, + { label: "Payments", href: "/candidate/payments" } ]; } diff --git a/tsconfig.json b/tsconfig.json index e7267b7..11f1439 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "e2e", "playwright.config.ts"] }
{profileState.error}
{uploadState.error}
No skills added yet.
No work experience added yet.
{readiness.missing.join(" · ")}
+ You have already {currentStatus === 1 ? "accepted" : "rejected"} this invitation. +
{state.error}