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
27 changes: 26 additions & 1 deletion app/(main)/positions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Briefcase } from 'lucide-react';

import { getPositionApplicationStats } from '@/prisma/data/applications';
import {
getAdminPositions,
getManagedPositions,
Expand All @@ -8,6 +9,7 @@ import {
} from '@/prisma/data/positions';

import { getOptionalUser } from '@/lib/auth/server';
import type { PositionApplicationStats } from '@/lib/types';

import { PositionCard } from '@/components/features/position-card';
import { PositionCreateDialog } from '@/components/features/position-create-dialog';
Expand All @@ -17,9 +19,13 @@ export default async function PositionsPage() {
const user = await getOptionalUser();
const isAdmin = user?.isAdmin ?? false;

// Admin branch: unchanged layout — flat list with create action.
// Admin branch: flat list with create action and application stats on every card.
if (isAdmin) {
const positions = await getAdminPositions();
const adminStatsByPosition =
positions.length > 0
? await getPositionApplicationStats(positions.map((p) => p.id))
: new Map<string, PositionApplicationStats>();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
Expand All @@ -41,6 +47,7 @@ export default async function PositionsPage() {
position={position}
canManage={true}
isAuthenticated={true}
applicationStats={adminStatsByPosition.get(position.id)}
/>
))}
</div>
Expand All @@ -61,6 +68,13 @@ export default async function PositionsPage() {
// Build a set of managed IDs so canManage can be derived in O(1) per card.
const managedIds = new Set(managedPositions.map((p) => p.id));

// Fetch application stats for managed positions — stats are cross-user aggregates
// safe to show to managers (see getPositionApplicationStats() for the invariant).
const statsByPosition =
managedIds.size > 0
? await getPositionApplicationStats([...managedIds])
: new Map<string, PositionApplicationStats>();

// Show "Positions I Manage" only when the user actually manages at least one
// relevant position — non-managers get an empty array, so the section is omitted.
const showManagedSection = managedPositions.length > 0;
Expand All @@ -85,6 +99,7 @@ export default async function PositionsPage() {
position={position}
canManage={true}
isAuthenticated={isAuthenticated}
applicationStats={statsByPosition.get(position.id)}
/>
))}
</div>
Expand Down Expand Up @@ -113,6 +128,11 @@ export default async function PositionsPage() {
position={position}
canManage={managedIds.has(position.id)}
isAuthenticated={isAuthenticated}
applicationStats={
managedIds.has(position.id)
? statsByPosition.get(position.id)
: undefined
}
/>
))}
</div>
Expand All @@ -135,6 +155,11 @@ export default async function PositionsPage() {
position={position}
canManage={managedIds.has(position.id)}
isAuthenticated={isAuthenticated}
applicationStats={
managedIds.has(position.id)
? statsByPosition.get(position.id)
: undefined
}
/>
))}
</div>
Expand Down
221 changes: 164 additions & 57 deletions components/features/position-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import Link from 'next/link';

import { Inbox, Pencil } from 'lucide-react';

import type { PositionWithQuestions } from '@/lib/types';
import { formatDate, getPositionAvailability } from '@/lib/utils';
import {
APPLICATION_STATUS_BADGE_VARIANT,
APPLICATION_STATUS_LABELS,
POSITION_CARD_STAT_STATUSES,
STATUS_BADGE_VARIANT_TO_DOT,
} from '@/lib/constants';
import type {
PositionApplicationStats,
PositionWithQuestions,
} from '@/lib/types';
import { cn, formatDate, getPositionAvailability } from '@/lib/utils';

import { PositionStatusBadge } from '@/components/features/status-badge';
import { Button } from '@/components/ui/button';
Expand All @@ -13,14 +22,78 @@ interface PositionCardProps {
position: PositionWithQuestions;
canManage?: boolean;
isAuthenticated?: boolean;
applicationStats?: PositionApplicationStats;
}

// Server component — accordion removed; flat summary card with always-visible
// truncated description and a CTA row linking into the detail page.
interface PositionStatClusterProps {
stats: PositionApplicationStats;
}

// Co-located server sub-component — compact count+label tiles for managed position cards.
// Renders a "Total" lead tile and a 2x2 grid of the four key pipeline statuses.
// Zero-count tiles are dimmed rather than hidden so the cluster shape is stable.
function PositionStatCluster({ stats }: PositionStatClusterProps) {
return (
<div role="region" aria-label="Application stats" className="shrink-0">
{/* Total tile — col-span-2 lead row with hairline divider below */}
<div className="border-border mb-2 border-b pb-2">
<div className="flex items-center gap-1.5">
<span
className="bg-primary size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
<p className="text-muted-foreground text-[11px] leading-tight">
Total
</p>
</div>
<p className="mt-1 text-xl leading-none font-semibold tabular-nums">
{stats.total}
</p>
</div>

{/* 2x2 grid of key pipeline statuses */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{POSITION_CARD_STAT_STATUSES.map((status) => {
const count = stats.counts[status] ?? 0;
const isDimmed = count === 0;
const variant = APPLICATION_STATUS_BADGE_VARIANT[status];
const dotClass =
STATUS_BADGE_VARIANT_TO_DOT[variant] ?? 'bg-muted-foreground';

return (
<div key={status}>
<div className="flex items-center gap-1.5">
<span
className={`size-1.5 shrink-0 rounded-full ${dotClass} ${isDimmed ? 'opacity-40' : ''}`}
aria-hidden="true"
/>
<p
className={`text-[11px] leading-tight ${isDimmed ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
>
{APPLICATION_STATUS_LABELS[status]}
</p>
</div>
<p
className={`mt-0.5 text-xl leading-none font-semibold tabular-nums ${isDimmed ? 'text-muted-foreground/60' : ''}`}
>
{count}
</p>
</div>
);
})}
</div>
</div>
);
}

// Server component — flat summary card with always-visible truncated description
// and a CTA row linking into the detail page. Managed cards (canManage=true) also
// show a top-right-anchored application stats cluster when applicationStats is passed.
export function PositionCard({
position,
canManage = false,
isAuthenticated = false,
applicationStats,
}: PositionCardProps) {
const availability = getPositionAvailability(position);
const isAccepting = availability === 'accepting';
Expand All @@ -38,65 +111,99 @@ export function PositionCard({

return (
<Card className="flex flex-col gap-0 p-0">
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg leading-snug">
{position.title}
</CardTitle>
<PositionStatusBadge position={position} />
</div>
</CardHeader>

<CardContent className="flex flex-col gap-3 px-4 pb-4">
<p className="text-muted-foreground line-clamp-2 text-sm">
{position.description}
</p>
{/* Two-column layout: when applicationStats is present the outer div becomes a
flex row at sm+, with CardHeader + CardContent in the left column and the
stat cluster in a right column that reaches the card's top edge.
One-column view (no applicationStats): wrapper and column divs add no classes,
card renders exactly as before. */}
<div className={cn(applicationStats && 'sm:flex sm:flex-row')}>
{/* Left column — header + content stacked; grows to fill available width */}
<div
className={cn(
applicationStats && 'sm:flex sm:min-w-0 sm:flex-1 sm:flex-col',
)}
>
<CardHeader className="p-4 pb-2">
<div
className={cn(
'flex items-center gap-2',
!applicationStats && 'justify-between',
)}
>
<CardTitle className="text-lg leading-snug">
{position.title}
</CardTitle>
<PositionStatusBadge position={position} />
</div>
</CardHeader>

{dateLabel && (
<p className="text-muted-foreground text-xs">{dateLabel}</p>
)}
<CardContent
className={cn(
'px-4 pb-4',
applicationStats && 'sm:flex sm:flex-1 sm:flex-col',
)}
>
<p className="text-muted-foreground line-clamp-2 text-sm">
{position.description}
</p>
{dateLabel && (
<p className="text-muted-foreground mt-1 text-xs">{dateLabel}</p>
)}

<div className="flex flex-wrap items-center gap-2 pt-1">
{canManage ? (
<>
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}`}>View Details</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}/edit`}>
<Pencil className="size-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/applications?positionId=${position.id}`}>
<Inbox className="size-4" />
Applications
</Link>
</Button>
</>
) : (
<>
{isAccepting && (
<Button asChild size="sm">
{isAuthenticated ? (
<Link href={`/positions/${position.id}/apply`}>Apply</Link>
) : (
<Link
href={`/login?redirectTo=/positions/${position.id}/apply`}
>
Apply
{/* mt-auto ensures buttons always sit at the bottom of the left column */}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
{canManage ? (
<>
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}`}>View Details</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}/edit`}>
<Pencil className="size-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/applications?positionId=${position.id}`}>
<Inbox className="size-4" />
Applications
Comment thread
b-at-neu marked this conversation as resolved.
</Link>
</Button>
</>
) : (
<>
{isAccepting && (
<Button asChild size="sm">
{isAuthenticated ? (
<Link href={`/positions/${position.id}/apply`}>
Apply
</Link>
) : (
<Link
href={`/login?redirectTo=/positions/${position.id}/apply`}
>
Apply
</Link>
)}
</Button>
)}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}`}>View Details</Link>
</Button>
</>
)}
<Button asChild variant="outline" size="sm">
<Link href={`/positions/${position.id}`}>View Details</Link>
</Button>
</>
)}
</div>
</CardContent>
</div>
</CardContent>

{/* Right column — stat cluster; stacks below left column on mobile,
sits beside it at sm+ in a two-column layout */}
{applicationStats && (
<div className="px-4 pb-4 sm:flex sm:shrink-0 sm:items-start sm:p-6 sm:pl-0">
<PositionStatCluster stats={applicationStats} />
</div>
)}
</div>
</Card>
);
}
11 changes: 11 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,14 @@ export const STATUS_BADGE_VARIANT_TO_DOT: Record<string, string> = {
default: 'bg-primary',
outline: 'bg-border',
};

// The four application statuses surfaced on position cards for admins/managers.
// Ordered: Applied → Interview scheduled → Accepted → Rejected.
// Shared between PositionStatCluster and any future per-position stat consumer
// so the displayed set is a single source of truth (ENGINEERING §1: abstract at 2+).
export const POSITION_CARD_STAT_STATUSES = [
'applied',
'interview_scheduled',
'accepted',
'rejected',
] as const satisfies $Enums.ApplicationStatus[];
11 changes: 10 additions & 1 deletion lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
PositionStatus,
QuestionType,
} from '@/prisma/client';
import type { Prisma } from '@/prisma/client';
import type { $Enums, Prisma } from '@/prisma/client';

import type { REVIEWER_APPLICATION_STATUSES } from '@/lib/constants';

Expand Down Expand Up @@ -230,6 +230,15 @@ export type AdminUserListItem = Prisma.UserGetPayload<{
};
}>;

// Per-position application stats for admin/manager position cards.
// Aggregate read-only shape — never exposes individual applicant identity.
// Must only be passed to cards for positions the caller manages.
export type PositionApplicationStats = {
positionId: string;
counts: Partial<Record<$Enums.ApplicationStatus, number>>;
total: number;
};

// Identity shape passed to nav components so sidebar and mobile nav agree
// on what to display in the user menu.
export interface NavIdentity {
Expand Down
Loading
Loading