diff --git a/apps/admin/src/app/dashboard/hr/candidates/[id]/actions.tsx b/apps/admin/src/app/dashboard/hr/candidates/[id]/actions.tsx new file mode 100644 index 0000000..4a29cd9 --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/[id]/actions.tsx @@ -0,0 +1,40 @@ +'use client'; +import { useTransition } from 'react'; +import { Button } from '@repo/ui/components/button'; +import { updateCandidateStage } from './update-stage.action'; +import { useToast } from '@repo/ui/components/use-toast'; + +const stages = ['SCREENING', 'TECH_INTERVIEW', 'SYSTEM_DESIGN', 'FINAL_INTERVIEW', 'OFFER', 'REJECTED'] as const; + +export function CandidateActions({ candidateId, currentStage }: { candidateId: string; currentStage: string }) { + const [pending, startTransition] = useTransition(); + const { toast } = useToast(); + + const handleStageChange = (stage: string) => { + startTransition(async () => { + const result = await updateCandidateStage(candidateId, stage); + if (result.success) { + toast({ title: 'Этап обновлён' }); + } else { + toast({ title: 'Ошибка', variant: 'destructive' }); + } + }); + }; + + return ( +
+ {stages.map((stage) => ( + + ))} +
+ ); +} diff --git a/apps/admin/src/app/dashboard/hr/candidates/[id]/page.tsx b/apps/admin/src/app/dashboard/hr/candidates/[id]/page.tsx new file mode 100644 index 0000000..49f61fe --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/[id]/page.tsx @@ -0,0 +1,114 @@ +import { auth } from '~/server/auth'; +import { prisma } from '@repo/db'; +import { assertAdminOrRecruiter } from '~/utils/auth-guards'; +import { notFound } from 'next/navigation'; +import { Badge } from '@repo/ui/components/badge'; +import { Button } from '@repo/ui/components/button'; +import Link from 'next/link'; +import { CandidateActions } from './actions'; + +const stageColors: Record = { + SCREENING: 'bg-blue-100 text-blue-800', + TECH_INTERVIEW: 'bg-yellow-100 text-yellow-800', + SYSTEM_DESIGN: 'bg-purple-100 text-purple-800', + FINAL_INTERVIEW: 'bg-orange-100 text-orange-800', + OFFER: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', +}; + +export default async function CandidateDetailPage(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params; + const session = await auth(); + assertAdminOrRecruiter(session); + + const candidate = await prisma.candidate.findUnique({ + where: { id }, + include: { + createdBy: { select: { name: true } }, + interviews: { + include: { + recruiter: { select: { name: true } }, + _count: { select: { challenges: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!candidate) notFound(); + + return ( +
+
+
+

+ {candidate.firstName} {candidate.lastName} +

+

{candidate.email}

+
+ + {candidate.pipelineStage.replace(/_/g, ' ')} + +
+ +
+
+

Информация

+
+
+
Телефон
+
{candidate.phone || '—'}
+
+
+
CV
+
{candidate.cvLink ? Ссылка : '—'}
+
+
+
Создан
+
{candidate.createdAt.toLocaleDateString()}
+
+
+
Создал
+
{candidate.createdBy.name}
+
+
+
+ +
+

Действия

+ +
+
+ + {candidate.notes && ( +
+

Заметки

+

{candidate.notes}

+
+ )} + +
+

Интервью ({candidate.interviews.length})

+ {candidate.interviews.length === 0 ? ( +

Нет интервью

+ ) : ( +
+ {candidate.interviews.map((interview) => ( +
+
+
{interview.title}
+
+ {interview.recruiter.name} · {interview._count.challenges} задач · {interview.status} +
+
+ + + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/app/dashboard/hr/candidates/[id]/update-stage.action.ts b/apps/admin/src/app/dashboard/hr/candidates/[id]/update-stage.action.ts new file mode 100644 index 0000000..4b82a9c --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/[id]/update-stage.action.ts @@ -0,0 +1,18 @@ +'use server'; +import { auth } from '~/server/auth'; +import { prisma } from '@repo/db'; +import { assertAdminOrRecruiter } from '~/utils/auth-guards'; +import { revalidatePath } from 'next/cache'; + +export async function updateCandidateStage(candidateId: string, stage: string) { + const session = await auth(); + assertAdminOrRecruiter(session); + + await prisma.candidate.update({ + where: { id: candidateId }, + data: { pipelineStage: stage as any }, + }); + + revalidatePath('/dashboard/hr'); + return { success: true }; +} diff --git a/apps/admin/src/app/dashboard/hr/candidates/columns.tsx b/apps/admin/src/app/dashboard/hr/candidates/columns.tsx new file mode 100644 index 0000000..b6e6298 --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/columns.tsx @@ -0,0 +1,59 @@ +'use client'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Badge } from '@repo/ui/components/badge'; +import { Button } from '@repo/ui/components/button'; +import Link from 'next/link'; + +type CandidateRow = { + id: string; + firstName: string; + lastName: string; + email: string; + pipelineStage: string; + status: string; + createdBy: { name: string | null }; + _count: { interviews: number }; +}; + +const stageColors: Record = { + SCREENING: 'bg-blue-100 text-blue-800', + TECH_INTERVIEW: 'bg-yellow-100 text-yellow-800', + SYSTEM_DESIGN: 'bg-purple-100 text-purple-800', + FINAL_INTERVIEW: 'bg-orange-100 text-orange-800', + OFFER: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: 'firstName', + header: 'Имя', + cell: ({ row }) => `${row.original.firstName} ${row.original.lastName}`, + }, + { accessorKey: 'email', header: 'Email' }, + { + accessorKey: 'pipelineStage', + header: 'Этап', + cell: ({ row }) => ( + + {row.original.pipelineStage.replace(/_/g, ' ')} + + ), + }, + { + accessorKey: '_count.interviews', + header: 'Интервью', + }, + { + accessorKey: 'createdBy.name', + header: 'Создал', + }, + { + id: 'actions', + cell: ({ row }) => ( + + + + ), + }, +]; diff --git a/apps/admin/src/app/dashboard/hr/candidates/create.action.ts b/apps/admin/src/app/dashboard/hr/candidates/create.action.ts new file mode 100644 index 0000000..f261566 --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/create.action.ts @@ -0,0 +1,32 @@ +'use server'; +import { auth } from '~/server/auth'; +import { prisma } from '@repo/db'; +import { assertAdminOrRecruiter } from '~/utils/auth-guards'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const schema = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email(), + phone: z.string().optional(), + cvLink: z.string().optional(), + notes: z.string().optional(), +}); + +export async function createCandidate(data: z.infer) { + const session = await auth(); + assertAdminOrRecruiter(session); + + const parsed = schema.parse(data); + + await prisma.candidate.create({ + data: { + ...parsed, + createdById: session!.user.id, + }, + }); + + revalidatePath('/dashboard/hr/candidates'); + return { success: true }; +} diff --git a/apps/admin/src/app/dashboard/hr/candidates/page.tsx b/apps/admin/src/app/dashboard/hr/candidates/page.tsx new file mode 100644 index 0000000..260ca49 --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/candidates/page.tsx @@ -0,0 +1,30 @@ +import { auth } from '~/server/auth'; +import { prisma } from '@repo/db'; +import { assertAdminOrRecruiter } from '~/utils/auth-guards'; +import { DataTable } from '@repo/ui/components/data-table'; +import { columns } from './columns'; + +export default async function CandidatesPage() { + const session = await auth(); + assertAdminOrRecruiter(session); + + const candidates = await prisma.candidate.findMany({ + include: { + createdBy: { select: { name: true } }, + _count: { select: { interviews: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return ( +
+
+
+

Кандидаты

+

Все кандидаты в системе

+
+
+ +
+ ); +} diff --git a/apps/admin/src/app/dashboard/hr/interviews/[id]/live/live-view.tsx b/apps/admin/src/app/dashboard/hr/interviews/[id]/live/live-view.tsx new file mode 100644 index 0000000..371206e --- /dev/null +++ b/apps/admin/src/app/dashboard/hr/interviews/[id]/live/live-view.tsx @@ -0,0 +1,135 @@ +'use client'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Badge } from '@repo/ui/components/badge'; +import { Button } from '@repo/ui/components/button'; +import { Textarea } from '@repo/ui/components/textarea'; +import { saveNote } from './save-note.action'; +import { useToast } from '@repo/ui/components/use-toast'; + +type ChallengeData = { + id: string; + challengeId: number; + order: number; + code: string; + isSubmitted: boolean; + passedTests: number; + totalTests: number; + challenge: { name: string; slug: string; difficulty: string; tests: string }; +}; + +type Interview = { + id: string; + title: string; + token: string; + status: string; + duration: number; + candidate: { firstName: string; lastName: string }; + challenges: ChallengeData[]; +}; + +export function LiveCodingView({ interview }: { interview: Interview }) { + const { toast } = useToast(); + const [activeChallengeIdx, setActiveChallengeIdx] = useState(0); + const [code, setCode] = useState(''); + const [note, setNote] = useState(''); + const [events, setEvents] = useState([]); + const pollingRef = useRef(null); + const challenge = interview.challenges[activeChallengeIdx]; + + useEffect(() => { + const poll = async () => { + try { + const res = await fetch(`/api/interview/poll?id=${interview.id}`); + const data = await res.json(); + + if (data.code) setCode(data.code); + if (data.events) setEvents(data.events.slice(-100)); + } catch {} + }; + + poll(); + pollingRef.current = setInterval(poll, 2000); + return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; + }, [interview.id, activeChallengeIdx]); + + const handleSaveNote = useCallback(async () => { + if (!note.trim()) return; + const result = await saveNote(interview.id, note); + if (result.success) { + toast({ title: 'Заметка сохранена' }); + setNote(''); + } + }, [note, interview.id, toast]); + + return ( +
+
+
+
+

Live: {interview.title}

+

+ {interview.candidate.firstName} {interview.candidate.lastName} +

+
+ LIVE +
+ +
+ {interview.challenges.map((ch, i) => ( + + ))} +
+ + {challenge && ( +
+
+ {challenge.challenge.name} + {challenge.challenge.difficulty} +
+
+              {code || 'Кандидат ещё не начал писать код...'}
+            
+
+ )} + +
+

Лог событий

+
+ {events.length === 0 && ( +

Ожидание действий кандидата...

+ )} + {events.map((ev: any, i: number) => ( +
+ {new Date(ev.timestamp).toLocaleTimeString()} + {' '} + {ev.type} +
+ ))} +
+
+
+ +
+
+

Заметки рекрутера

+