A submission card with a status pill, due countdown that turns destructive when overdue, a graded rubric with per-criterion bars, an attached file, and submit.
npx shadcn@latest add @paragon/assignment-submissionAlso installs: button
"use client";
import * as React from "react";
import {
FileText,
Clock,
CheckCircle2,
AlertTriangle,
Upload,
Circle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
type Status = "not-started" | "draft" | "submitted" | "graded" | "late";
export interface RubricCriterion {
label: string;
/** Points earned (only shown when graded). */
earned?: number;
/** Points possible. */
max: number;
}
export interface AssignmentSubmissionProps
extends React.ComponentProps<"div"> {
title?: string;
course?: string;
status?: Status;
/** Hours until due (negative = overdue). Ignored when graded/submitted. */
hoursLeft?: number;
rubric?: RubricCriterion[];
/** Attached file name, if any. */
file?: string;
onSubmit?: () => void;
}
const DEFAULT_RUBRIC: RubricCriterion[] = [
{ label: "Correctness", earned: 28, max: 30 },
{ label: "Code style", earned: 18, max: 20 },
{ label: "Tests & coverage", earned: 22, max: 25 },
{ label: "Documentation", earned: 20, max: 25 },
];
const STATUS_META: Record<
Status,
{ label: string; tone: string; icon: React.ElementType }
> = {
"not-started": {
label: "Not started",
tone: "bg-secondary text-muted-foreground",
icon: Circle,
},
draft: { label: "Draft saved", tone: "bg-secondary text-muted-foreground", icon: FileText },
submitted: {
label: "Submitted",
tone: "bg-success/10 text-success",
icon: CheckCircle2,
},
graded: {
label: "Graded",
tone: "bg-primary/10 text-primary",
icon: CheckCircle2,
},
late: { label: "Late", tone: "bg-destructive/10 text-destructive", icon: AlertTriangle },
};
function formatCountdown(hours: number) {
const overdue = hours < 0;
const abs = Math.abs(hours);
const d = Math.floor(abs / 24);
const h = Math.round(abs % 24);
const text = d > 0 ? `${d}d ${h}h` : `${h}h`;
return overdue ? `${text} overdue` : `Due in ${text}`;
}
/**
* An assignment submission card: header with course and a status pill, a due
* countdown that turns destructive when overdue, a rubric that renders as a
* criteria list (with earned/max and per-row bars once graded), an optional
* attached file, and a submit action. Deterministic, tabular figures.
*/
export function AssignmentSubmission({
title = "Assignment 4 — Balanced Binary Trees",
course = "CS 202 · Data Structures",
status = "graded",
hoursLeft = 18,
rubric = DEFAULT_RUBRIC,
file = "avl-tree.zip",
onSubmit,
className,
...props
}: AssignmentSubmissionProps) {
const meta = STATUS_META[status];
const graded = status === "graded";
const earned = rubric.reduce((a, r) => a + (r.earned ?? 0), 0);
const total = rubric.reduce((a, r) => a + r.max, 0);
const showCountdown = status === "not-started" || status === "draft" || status === "late";
return (
<div
data-slot="assignment-submission"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{course}</p>
<h3 className="text-sm font-semibold leading-tight text-balance">
{title}
</h3>
</div>
<span
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
meta.tone,
)}
>
<meta.icon className="size-3" />
{meta.label}
</span>
</div>
{showCountdown && (
<div
className={cn(
"mt-3 flex items-center gap-1.5 text-xs font-medium",
hoursLeft < 0 ? "text-destructive" : "text-muted-foreground",
)}
>
<Clock className="size-3.5" />
{formatCountdown(hoursLeft)}
</div>
)}
{graded && (
<div className="mt-3 flex items-baseline gap-2">
<span className="text-2xl font-semibold tabular-nums">{earned}</span>
<span className="text-sm text-muted-foreground tabular-nums">
/ {total} pts · {Math.round((earned / total) * 100)}%
</span>
</div>
)}
<div className="mt-4 space-y-2.5">
{rubric.map((r) => {
const pct = graded && r.earned != null ? r.earned / r.max : 0;
return (
<div key={r.label}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{r.label}</span>
<span className="font-medium tabular-nums">
{graded && r.earned != null ? `${r.earned}/${r.max}` : `${r.max} pts`}
</span>
</div>
{graded && (
<div className="mt-1 h-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full origin-left rounded-full transition-transform duration-500 ease-out",
pct >= 0.9 ? "bg-success" : pct >= 0.7 ? "bg-warning" : "bg-destructive",
)}
style={{ transform: `scaleX(${pct})` }}
/>
</div>
)}
</div>
);
})}
</div>
{file && (
<div className="mt-4 flex items-center gap-2 rounded-lg bg-secondary/50 px-3 py-2">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{file}</span>
<span className="shrink-0 text-xs text-muted-foreground">2.4 MB</span>
</div>
)}
{!graded && status !== "submitted" && (
<Button className="mt-4 w-full" onClick={onSubmit}>
<Upload /> Submit assignment
</Button>
)}
</div>
);
}