Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "public"."AssessmentStatus" ADD VALUE 'NOT_SENT';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ model Candidate {

enum AssessmentStatus {
NOT_ASSIGNED // Application created, no assessment assigned yet
NOT_SENT // Assessment created, but link has not been sent to candidate yet
NOT_STARTED // Assessment created and link sent to candidate, but candidate has not started yet
SUBMITTED // Candidate completed and submitted assessment
EXPIRED // Assessment deadline passed without submission
Expand Down
48 changes: 39 additions & 9 deletions src/app/(web)/crm/assessment-templates/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { use, useState } from 'react';
import { ChevronDown, Users } from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
import { Button } from '@/lib/components/ui/Button';
import AddTaskModal from '@/lib/components/modal/AddTaskModal';
import AssessmentEditorSidebar from '@/lib/components/core/AssessmentEditorSidebar';
import AssessmentTaskPreviewPanel from '@/lib/components/core/AssessmentTaskPreviewPanel';
import Breadcrumbs from '@/lib/components/core/Breadcrumbs';
import useAssessmentTemplateEditPage from '@/lib/hooks/useAssessmentTemplateEditPage';
import { Combobox } from '@/lib/components/ui/Combobox';

export default function AssessmentTemplateEditPage({
params,
Expand All @@ -22,6 +22,8 @@ export default function AssessmentTemplateEditPage({
isLoading,
error,
title,
positions,
selectedPositionId,
sections,
notes,
selectedSection,
Expand All @@ -33,17 +35,19 @@ export default function AssessmentTemplateEditPage({
deleteSection,
reorderSections,
selectSection,
updateSelectedPosition,
save,
} = useAssessmentTemplateEditPage(id);

const [addTaskOpen, setAddTaskOpen] = useState(false);

const onSave = async () => {
const success = await save();
if (success) {
const result = await save();
if (result.success) {
toast.success('Assessment template saved');
} else {
toast.error('Failed to save assessment template');
const message = result.errorMessage ?? 'Failed to save assessment template';
toast.error(message);
}
};

Expand Down Expand Up @@ -75,6 +79,10 @@ export default function AssessmentTemplateEditPage({
}

const alreadyAddedIds = new Set(sections.map((s) => s.taskTemplateId));
const positionOptions = positions.map((position) => ({
value: position.id,
label: position.title,
}));

return (
<div className="flex h-full flex-col">
Expand All @@ -86,11 +94,33 @@ export default function AssessmentTemplateEditPage({
onCurrentPageChange={updateTitle}
/>

<Button variant="dropdown" className="shrink-0">
<Users className="size-5" />
Assign to position
<ChevronDown className="size-5" />
</Button>
<div className="w-full max-w-[320px] shrink-0">
<Combobox
options={positionOptions}
value={selectedPositionId ?? undefined}
onChange={(value) => updateSelectedPosition((value as string) || null)}
placeholder="Assign to a position"
searchPlaceholder="Search positions..."
emptyText="No positions found."
showSearchIcon
triggerClassName="h-14 rounded-xl border px-4"
contentClassName="rounded-xl"
trigger={
<button
type="button"
className="border-sarge-gray-200 bg-sarge-gray-0 text-sarge-gray-800 flex h-14 w-full items-center gap-3 rounded-xl border px-4 text-left"
>
<Users className="text-sarge-gray-600 size-5 shrink-0" />
<span className="text-label-s min-w-0 flex-1 truncate">
{positions.find(
(position) => position.id === selectedPositionId
)?.title ?? 'Assign to a position'}
</span>
<ChevronDown className="text-sarge-gray-600 size-5 shrink-0" />
</button>
}
/>
</div>
</div>

<div className="flex min-h-0 flex-1">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type NextRequest } from 'next/server';
import { getSession } from '@/lib/utils/auth.utils';
import { handleError } from '@/lib/utils/errors.utils';
import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils';
import AssessmentService from '@/lib/services/assessment.service';

export async function PUT(
request: NextRequest,
{
params,
}: {
params: Promise<{ id: string; assessmentTemplateId: string }>;
}
) {
try {
const session = await getSession();
await assertRecruiterOrAbove(request.headers);

const { id, assessmentTemplateId } = await params;

const result = await AssessmentService.assignTemplateToPosition({
positionId: id,
assessmentTemplateId,
orgId: session.activeOrganizationId,
});

return Response.json({ data: result }, { status: 200 });
} catch (err) {
return handleError(err);
}
}
2 changes: 1 addition & 1 deletion src/app/api/positions/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ export async function GET(request: NextRequest) {
const positions = await PositionService.getPositionsByTitle(title, orgId);
return Response.json({ data: positions }, { status: 200 });
} catch (err) {
handleError(err);
return handleError(err);
}
}
29 changes: 26 additions & 3 deletions src/lib/api/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ export async function getPosition(positionId: string): Promise<Position> {
}

/**
* GET api/positions?orgId=
* GET /api/positions
*/
export async function getPositionsByOrgId(orgId: string): Promise<PositionWithCounts[]> {
const res = await fetch(`/api/positions?orgId=${orgId}`);
export async function getPositions(): Promise<PositionWithCounts[]> {
const res = await fetch('/api/positions');

const json = await res.json();

Expand Down Expand Up @@ -170,3 +170,26 @@ export async function searchPositions(title: string): Promise<PositionWithCounts

return json.data;
}

/**
* PUT /api/positions/:positionId/assessment-template/:assessmentTemplateId
*/
export async function assignAssessmentTemplateToPosition(
positionId: string,
assessmentTemplateId: string
): Promise<{ positionId: string; assessmentTemplateId: string; assessmentsCreated: number }> {
const res = await fetch(
`/api/positions/${positionId}/assessment-template/${assessmentTemplateId}`,
{
method: 'PUT',
}
);

const json = await res.json();

if (!res.ok) {
throw new Error(json.message);
}

return json.data;
}
1 change: 1 addition & 0 deletions src/lib/components/core/CandidateTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const getAssessmentLabel = (status?: string) => {
const s = (status ?? '').toUpperCase();
if (s === 'GRADED') return 'Graded';
if (s === 'SUBMITTED') return 'Submitted';
if (s === 'NOT_SENT') return 'Not sent';
if (s === 'NOT_STARTED') return 'Not started';
if (s === 'NOT_ASSIGNED') return 'Not assigned';
if (s === 'EXPIRED') return 'Expired';
Expand Down
13 changes: 5 additions & 8 deletions src/lib/components/core/PositionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import { getSubmissionVariant } from '@/lib/utils/status.utils';
type PositionCardProps = {
title: string;
candidateCount: number;
assessmentName?: string;
assignedCount?: number;
sentCount?: number;
submittedCount?: number;
totalAssigned?: number;
className?: string;
onPositionClick?: () => void;
onAssessmentClick?: () => void;
Expand All @@ -20,14 +18,13 @@ type PositionCardProps = {
export default function PositionCard({
title,
candidateCount,
assignedCount = 0,
sentCount = 0,
submittedCount = 0,
totalAssigned = 0,
className,
onPositionClick,
onAssessmentClick,
}: PositionCardProps) {
const submissionVariant = getSubmissionVariant(submittedCount, totalAssigned);
const submissionVariant = getSubmissionVariant(submittedCount, sentCount);

return (
<div
Expand Down Expand Up @@ -84,9 +81,9 @@ export default function PositionCard({
onAssessmentClick?.();
}}
>
<Chip variant="neutral">{assignedCount} sent</Chip>
<Chip variant="neutral">{sentCount} assigned</Chip>
<Chip variant={submissionVariant}>
{submittedCount}/{totalAssigned} submitted
{submittedCount}/{sentCount} submitted
</Chip>
</PositionAssessmentCard>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/core/PositionsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ function PositionCardGrid({
key={position.id}
title={position.title}
candidateCount={position.numCandidates}
assessmentName={''}
sentCount={position.assessmentSentCount}
submittedCount={position.assessmentSubmittedCount}
onPositionClick={() => onPositionClick(position.id)}
/>
))}
Expand Down
38 changes: 33 additions & 5 deletions src/lib/hooks/useAssessmentTemplateEditPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import {
updateAssessmentTemplate,
updateAssessmentTemplateTasks,
} from '@/lib/api/assessment-templates';
import { assignAssessmentTemplateToPosition, getPositions } from '@/lib/api/positions';
import type { AssessmentSection } from '@/lib/types/assessment-section.types';
import type { BlockNoteContent } from '@/lib/types/task-template.types';
import type { TaskTemplateListItemDTO } from '@/lib/schemas/task-template.schema';
import type { PositionWithCounts } from '@/lib/types/position.types';

export default function useAssessmentTemplateEditPage(assessmentTemplateId: string) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

const [title, setTitle] = useState('');
const [positions, setPositions] = useState<PositionWithCounts[]>([]);
const [selectedPositionId, setSelectedPositionId] = useState<string | null>(null);
const [sections, setSections] = useState<AssessmentSection[]>([]);
const [notes, setNotes] = useState<BlockNoteContent>([]);
const [selectedSection, setSelectedSection] = useState<AssessmentSection | null>(null);
Expand All @@ -27,12 +31,17 @@ export default function useAssessmentTemplateEditPage(assessmentTemplateId: stri
try {
setIsLoading(true);
setError(null);
const template = await getAssessmentTemplate(assessmentTemplateId);
const [template, availablePositions] = await Promise.all([
getAssessmentTemplate(assessmentTemplateId),
getPositions(),
]);

if (cancelled) return;

setTitle(template.title);
setNotes(template.notes);
setPositions(availablePositions);
setSelectedPositionId(template.positions[0]?.id ?? null);

const initialSections: AssessmentSection[] = template.tasks.map((t) => ({
type: 'task' as const,
Expand Down Expand Up @@ -113,7 +122,12 @@ export default function useAssessmentTemplateEditPage(assessmentTemplateId: stri
setSelectedSection(section);
}

async function save(): Promise<boolean> {
function updateSelectedPosition(positionId: string | null) {
setSelectedPositionId(positionId);
setHasUnsavedChanges(true);
}

async function save(): Promise<{ success: boolean; errorMessage?: string }> {
setIsSaving(true);
try {
await Promise.all([
Expand All @@ -126,10 +140,21 @@ export default function useAssessmentTemplateEditPage(assessmentTemplateId: stri
sections.map((s) => ({ taskTemplateId: s.taskTemplateId }))
),
]);

if (selectedPositionId) {
await assignAssessmentTemplateToPosition(selectedPositionId, assessmentTemplateId);
}

setHasUnsavedChanges(false);
return true;
} catch {
return false;
return { success: true };
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to save assessment template';

return {
success: false,
errorMessage,
};
} finally {
setIsSaving(false);
}
Expand All @@ -139,6 +164,8 @@ export default function useAssessmentTemplateEditPage(assessmentTemplateId: stri
isLoading,
error,
title,
positions,
selectedPositionId,
sections,
notes,
selectedSection,
Expand All @@ -150,6 +177,7 @@ export default function useAssessmentTemplateEditPage(assessmentTemplateId: stri
deleteSection,
reorderSections,
selectSection,
updateSelectedPosition,
save,
};
}
4 changes: 4 additions & 0 deletions src/lib/hooks/useCreatePositionModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export default function useCreatePositionModal(
...newPosition,
numCandidates: 0,
numAssigned: 0,
assessmentTemplateId: null,
assessmentTemplateTitle: null,
assessmentSentCount: 0,
assessmentSubmittedCount: 0,
};

setActive((prev) => [...prev, positionWithCounts]);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/hooks/usePositionsContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { type PositionWithCounts } from '@/lib/types/position.types';
import { useSession } from '@/lib/auth/auth-client';
import { getPositionsByOrgId } from '@/lib/api/positions';
import { getPositions } from '@/lib/api/positions';

function usePositionContent() {
const { data: session } = useSession();
Expand All @@ -24,7 +24,7 @@ function usePositionContent() {

setLoading(true);

const positions = await getPositionsByOrgId(activeOrganizationId);
const positions = await getPositions();

const activePositions = positions.filter((pos) => !pos.archived);
const archivedPositions = positions.filter((pos) => pos.archived);
Expand Down
Loading
Loading