Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/admin/src/app/dashboard/hr/candidates/[id]/actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
{stages.map((stage) => (
<Button
key={stage}
variant={currentStage === stage ? 'default' : 'outline'}
size="sm"
className="w-full justify-start"
disabled={pending}
onClick={() => handleStageChange(stage)}
>
{stage.replace(/_/g, ' ')}
</Button>
))}
</div>
);
}
114 changes: 114 additions & 0 deletions apps/admin/src/app/dashboard/hr/candidates/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-medium">
{candidate.firstName} {candidate.lastName}
</h3>
<p className="text-muted-foreground text-sm">{candidate.email}</p>
</div>
<Badge className={stageColors[candidate.pipelineStage] || ''}>
{candidate.pipelineStage.replace(/_/g, ' ')}
</Badge>
</div>

<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border p-4">
<h4 className="mb-2 font-medium">Информация</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Телефон</dt>
<dd>{candidate.phone || '—'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">CV</dt>
<dd>{candidate.cvLink ? <a href={candidate.cvLink} className="text-blue-500 underline" target="_blank">Ссылка</a> : '—'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Создан</dt>
<dd>{candidate.createdAt.toLocaleDateString()}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Создал</dt>
<dd>{candidate.createdBy.name}</dd>
</div>
</dl>
</div>

<div className="rounded-lg border p-4">
<h4 className="mb-2 font-medium">Действия</h4>
<CandidateActions candidateId={candidate.id} currentStage={candidate.pipelineStage} />
</div>
</div>

{candidate.notes && (
<div className="rounded-lg border p-4">
<h4 className="mb-2 font-medium">Заметки</h4>
<p className="text-muted-foreground text-sm whitespace-pre-wrap">{candidate.notes}</p>
</div>
)}

<div className="rounded-lg border p-4">
<h4 className="mb-3 font-medium">Интервью ({candidate.interviews.length})</h4>
{candidate.interviews.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет интервью</p>
) : (
<div className="space-y-2">
{candidate.interviews.map((interview) => (
<div key={interview.id} className="flex items-center justify-between rounded bg-muted p-3">
<div>
<div className="font-medium">{interview.title}</div>
<div className="text-muted-foreground text-xs">
{interview.recruiter.name} · {interview._count.challenges} задач · {interview.status}
</div>
</div>
<Link href={`/dashboard/hr/interviews/${interview.id}`}>
<Button variant="outline" size="sm">Открыть</Button>
</Link>
</div>
))}
</div>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 };
}
59 changes: 59 additions & 0 deletions apps/admin/src/app/dashboard/hr/candidates/columns.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<CandidateRow>[] = [
{
accessorKey: 'firstName',
header: 'Имя',
cell: ({ row }) => `${row.original.firstName} ${row.original.lastName}`,
},
{ accessorKey: 'email', header: 'Email' },
{
accessorKey: 'pipelineStage',
header: 'Этап',
cell: ({ row }) => (
<Badge className={stageColors[row.original.pipelineStage] || ''}>
{row.original.pipelineStage.replace(/_/g, ' ')}
</Badge>
),
},
{
accessorKey: '_count.interviews',
header: 'Интервью',
},
{
accessorKey: 'createdBy.name',
header: 'Создал',
},
{
id: 'actions',
cell: ({ row }) => (
<Link href={`/dashboard/hr/candidates/${row.original.id}`}>
<Button variant="link">Открыть</Button>
</Link>
),
},
];
32 changes: 32 additions & 0 deletions apps/admin/src/app/dashboard/hr/candidates/create.action.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>) {
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 };
}
30 changes: 30 additions & 0 deletions apps/admin/src/app/dashboard/hr/candidates/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Кандидаты</h3>
<p className="text-muted-foreground text-sm">Все кандидаты в системе</p>
</div>
</div>
<DataTable data={candidates} columns={columns} />
</div>
);
}
Loading