diff --git a/src/app/inspector/id-requests/[id]/page.tsx b/src/app/inspector/id-requests/[id]/page.tsx index 70bd7f4..c717fea 100644 --- a/src/app/inspector/id-requests/[id]/page.tsx +++ b/src/app/inspector/id-requests/[id]/page.tsx @@ -39,6 +39,10 @@ export default async function InspectorIdRequestDetailPage({ params }: { params: : []) ]} /> + ); } diff --git a/src/app/styles.css b/src/app/styles.css index b4023c1..cd5291f 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -6257,6 +6257,10 @@ kbd { color: var(--blue); } + .shell { + display: block; + } + .topbar { grid-template-columns: 1fr; padding: 14px; @@ -6293,6 +6297,56 @@ kbd { display: none; } + .workspaceRail { + display: none; + } + + .workspaceStage { + width: auto; + padding: 10px 10px 88px; + } + + .mobileTabBar { + position: fixed; + right: 10px; + bottom: 10px; + left: 10px; + z-index: 30; + display: flex; + gap: 6px; + overflow-x: auto; + border: 1px solid #c5cfdd; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow); + padding: 8px; + scrollbar-width: none; + } + + .mobileTabBar::-webkit-scrollbar { + display: none; + } + + .mobileTabBar a { + min-height: 44px; + min-width: max-content; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + background: #fbfcfe; + color: var(--ink); + padding: 0 12px; + font-size: 13px; + font-weight: 800; + text-decoration: none; + } + + .mobileTabBar a.active { + border-color: var(--blue); + background: #eef5ff; + color: var(--blue); + } + .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; @@ -9057,86 +9111,11 @@ kbd { .candidateProfileActions a { flex: 1 1 120px; - min-height: 44px; - } - - .candidateTabs > a, - .candidateTabs > span { - min-height: 44px; } .candidateFactGrid div { border-right: 0; } - - .candidateFactGrid strong { - font-size: 14px; - } - - .candidateProfileTitle h2 { - font-size: 26px; - } -} - -@media (max-width: 480px) { - .candidateProfileHero { - grid-template-columns: 1fr; - justify-items: center; - text-align: center; - gap: 10px; - padding: 12px; - } - - .candidateAvatar { - width: 56px; - height: 56px; - font-size: 17px; - } - - .candidateProfileTitle { - justify-items: center; - } - - .candidateProfileTitle h2 { - font-size: 22px; - } - - .candidateStatusLine { - justify-content: center; - } - - .candidateProfileActions, - .candidateReadiness, - .candidateProfileColumns { - padding-inline: 10px; - } - - .candidateProfileActions a { - flex: 1 1 100%; - min-height: 44px; - } - - .candidateReadinessScore strong { - font-size: 28px; - } - - .candidateFactGrid strong { - font-size: 13px; - } - - .candidateProfilePanel { - min-width: 0; - } - - .candidatePills, - .candidateRows { - padding: 10px; - } - - .candidateRows a, - .candidateRows article { - padding: 10px 8px; - } } /* Staff operating home */ @@ -9965,229 +9944,6 @@ kbd { animation: spin 0.6s linear infinite; } -/* ── Request fulfillment stage actions ──────────────────────────────── */ -.stageActions { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 8px; -} - -.inlineForm { - display: flex; - align-items: center; - gap: 6px; -} - -.compactTextarea { - width: 200px; - padding: 4px 8px; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.8125rem; - line-height: 1.4; - resize: vertical; - background: var(--background); - color: var(--foreground); -} - -.stageActionError { - display: block; - width: 100%; - font-size: 0.8125rem; - color: var(--color-destructive, #dc2626); - line-height: 1.4; -} - -.noticeBanner { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 16px; - border-radius: 8px; - font-size: 0.875rem; - font-weight: 500; - margin-bottom: 16px; -} - -.noticeBannerSuccess { - background: var(--color-success-bg, #dcfce7); - color: var(--color-success-fg, #166534); - border: 1px solid var(--color-success-border, #bbf7d0); -} - -.noticeBannerError { - background: var(--color-destructive-bg, #fef2f2); - color: var(--color-destructive, #dc2626); - border: 1px solid var(--color-destructive-border, #fecaca); -} - -.noticeBanner span { - flex: 1; -} - -.noticeBannerDismiss { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - border-radius: 4px; - background: transparent; - cursor: pointer; - opacity: 0.6; -} - -.noticeBannerDismiss:hover { - opacity: 1; -} - -/* ── Match actions row ─────────────────────────────────────────────── */ -.matchActionsRow { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 8px; -} - -/* ── Story form ─────────────────────────────────────────────────────── */ -.storyForm { - display: flex; - gap: 8px; - margin-bottom: 12px; -} - -/* ── Request rows ───────────────────────────────────────────────────── */ -.requestRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 0; - border-bottom: 1px solid var(--line); -} - -.requestRow:last-child { - border-bottom: none; -} - -.requestRowLink { - display: grid; - gap: 2px; - min-width: 0; - text-decoration: none; - color: inherit; - cursor: pointer; -} - -.requestRowLink strong { - color: #111827; - font-size: 0.9375rem; -} - -.requestRowLink span { - color: #64748b; - font-size: 0.8125rem; -} - -.requestRowLink small { - color: #94a3b8; - font-size: 0.75rem; -} - -.requestRowActions { - display: flex; - gap: 4px; - flex-shrink: 0; -} - -/* ── Candidate edit form extras ─────────────────────────────────────── */ -.editableList { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.editableList li { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 8px; - border-radius: 6px; - background: var(--surface); -} - -.editableList li span { - font-size: 0.9375rem; -} - -.removeButton { - padding: 2px 10px; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.8125rem; - background: transparent; - color: var(--muted-foreground); - cursor: pointer; -} - -.removeButton:hover { - border-color: var(--destructive); - color: var(--destructive); -} - -.inlineFields { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -.inlineFields > * { - flex: 1; - min-width: 180px; -} - -.checkboxLabel { - display: flex; - align-items: center; - gap: 8px; - font-weight: normal; -} - -/* ── Candidate profile missing fields ───────────────────────────────── */ -.candidateMissingFields { - padding: 10px 14px; - border-radius: 10px; - background: rgba(245, 158, 11, 0.1); - border: 1px solid rgba(245, 158, 11, 0.2); -} - -.candidateMissingFields span { - font-size: 0.8125rem; - font-weight: 600; - color: #92400e; - text-transform: uppercase; -} - -.candidateMissingFields p { - margin: 4px 0 0; - font-size: 0.875rem; - color: #78350f; -} - -/* ── Detail panel note ──────────────────────────────────────────────── */ -.detailPanelNote { - padding: 12px 18px; - margin: 0; - font-size: 0.875rem; - color: var(--muted-foreground); - border-bottom: 1px solid var(--line); -} - @keyframes spin { to { transform: rotate(360deg); @@ -10513,7 +10269,6 @@ kbd { to { transform: translateY(20%); } } -/* ── Candidate profile completeness ── */ .candidateMissingFields { grid-column: 1 / -1; @@ -10553,5 +10308,5 @@ kbd { } .candidateMissingFields li a:hover { - color: var(--fg); + color: var(--foreground); } diff --git a/src/modules/candidates/actions.ts b/src/modules/candidates/actions.ts index 65cabcb..7180b86 100644 --- a/src/modules/candidates/actions.ts +++ b/src/modules/candidates/actions.ts @@ -534,11 +534,8 @@ export async function removeCandidateExperience(_prevState: { error: string }, f const certificateSchema = z.object({ certificate_type: z.string().transform((v) => v === "true").pipe(z.boolean()), - certificate_title: z.string().min(1, "Certificate title is required.").max(200, "Title must be under 200 characters."), - certificate_issuer: z.string().max(200, "Issuer must be under 200 characters.").optional(), start_date: z.string().max(10).optional(), end_date: z.string().max(10).optional(), - certificate_url: z.string().url("Please enter a valid URL.").max(500, "URL must be under 500 characters.").optional().or(z.literal("")), }); export async function addCandidateCertificate(_prevState: { error: string }, formData: FormData) { @@ -546,23 +543,17 @@ export async function addCandidateCertificate(_prevState: { error: string }, for const candidateId = Number(session.id); const parsed = certificateSchema.safeParse({ certificate_type: formData.get("certificate_type"), - certificate_title: formData.get("certificate_title"), - certificate_issuer: formData.get("certificate_issuer") || undefined, start_date: formData.get("start_date") || undefined, end_date: formData.get("end_date") || undefined, - certificate_url: formData.get("certificate_url") || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed." }; - const { certificate_type, certificate_title, certificate_issuer, start_date, end_date, certificate_url } = parsed.data; + const { certificate_type, start_date, end_date } = parsed.data; const now = new Date(); await prisma.candidate_certificate.create({ data: { certificate_uuid: `cert_${crypto.randomUUID()}`, candidate_id: candidateId, certificate_type, - certificate_title, - certificate_issuer: certificate_issuer || null, - certificate_url: certificate_url || null, start_date: start_date ? (isFinite(new Date(start_date).getTime()) ? new Date(start_date) : null) : null, end_date: end_date ? (isFinite(new Date(end_date).getTime()) ? new Date(end_date) : null) : null, is_deleted: false, @@ -1094,7 +1085,7 @@ export async function approveIdRequest(_prevState: { error: string }, formData: }); if (!request) return { error: "ID request not found." }; - if (request.status === "approved") return { error: "This request is already approved." }; + if (request.status !== "pending") return { error: "This request can only be processed from 'pending' status." }; const staffId = Number(session.id); const now = new Date(); @@ -1146,7 +1137,7 @@ export async function rejectIdRequest(_prevState: { error: string }, formData: F }); if (!request) return { error: "ID request not found." }; - if (request.status === "rejected") return { error: "This request is already rejected." }; + if (request.status !== "pending") return { error: "This request can only be processed from 'pending' status." }; const staffId = Number(session.id); const now = new Date(); diff --git a/src/modules/workspace/data.ts b/src/modules/workspace/data.ts index fe8ff2d..bfd7630 100644 --- a/src/modules/workspace/data.ts +++ b/src/modules/workspace/data.ts @@ -1018,9 +1018,6 @@ export async function getCandidateDetail(candidateId: number, requestBasePath = select: { certificate_uuid: true, certificate_type: true, - certificate_title: true, - certificate_issuer: true, - certificate_url: true, start_date: true, end_date: true, company_candidate_certificate_company_idTocompany: { select: { company_name: true } }, @@ -1149,6 +1146,9 @@ export async function getCandidateDetail(candidateId: number, requestBasePath = majorUuid: item.major_uuid, graduationYear: item.graduation_year, isCurrentlyStudying: item.is_currently_studying ?? false, + universityLabel: item.university?.university_name_en ?? "", + degreeLabel: item.degree?.degree_name_en ?? undefined, + majorLabel: item.major?.major_name_en ?? undefined, })), experiences: experiences.map((item) => ({ id: item.candidate_experience_id, @@ -1158,8 +1158,8 @@ export async function getCandidateDetail(candidateId: number, requestBasePath = })), certificates: certificates.map((item) => ({ id: item.certificate_uuid, - title: item.certificate_title ?? item.company_candidate_certificate_company_idTocompany?.company_name ?? item.store?.store_name ?? "Certificate", - subtitle: item.certificate_issuer ?? (item.certificate_type ? "Experience certificate" : "Certificate"), + title: item.company_candidate_certificate_company_idTocompany?.company_name ?? item.store?.store_name ?? "Certificate", + subtitle: item.certificate_type ? "Experience certificate" : "Certificate", meta: `${formatDate(item.start_date)} to ${formatDate(item.end_date)} · ${item.staff?.staff_name ?? "No staff owner"}` })), languages: languages.map((item) => ({