EdTech

Assignment Submission

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.

Install

npx shadcn@latest add @paragon/assignment-submission

Also installs: button

assignment-submission.tsx

"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>
  );
}