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} + + + + + + + + + + + +

Location & education

+ + + + + +