All components

Task Detail

Task Detail is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT other

What it is

Task Detail is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i nuqs

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Task Detail

registry/new-york/blocks/tasks/task-detail.tsx
"use client";

import {
  Calendar,
  FileText,
  MessageSquare,
  MoreVertical,
  Paperclip,
  Plus,
} from "lucide-react";
import { parseAsStringEnum, useQueryState } from "nuqs";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/registry/new-york/ui/avatar";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import { Checkbox } from "@/registry/new-york/ui/checkbox";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/registry/new-york/ui/dialog";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import { Input } from "@/registry/new-york/ui/input";
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/registry/new-york/ui/tabs";
import { Textarea } from "@/registry/new-york/ui/textarea";
import type { Task, TaskAssignee, TaskPriority, TaskStatus } from "./task-list";

export interface TaskComment {
  id: string;
  content: string;
  author: TaskAssignee;
  createdAt: Date;
  updatedAt?: Date;
}

export interface TaskAttachment {
  id: string;
  name: string;
  url: string;
  size: number;
  type: string;
  uploadedBy: TaskAssignee;
  uploadedAt: Date;
}

export interface TaskActivity {
  id: string;
  type: "created" | "updated" | "assigned" | "commented" | "completed";
  user: TaskAssignee;
  description: string;
  timestamp: Date;
}

export interface Subtask {
  id: string;
  title: string;
  completed: boolean;
  dueDate?: Date;
}

export interface TaskDetailProps {
  task?: Task | null;
  comments?: TaskComment[];
  attachments?: TaskAttachment[];
  activities?: TaskActivity[];
  subtasks?: Subtask[];
  onUpdate?: (updates: Partial<Task>) => Promise<void>;
  onDelete?: () => Promise<void>;
  onCommentAdd?: (content: string) => Promise<void>;
  onAttachmentUpload?: (file: File) => Promise<void>;
  onSubtaskToggle?: (subtaskId: string, completed: boolean) => Promise<void>;
  onSubtaskAdd?: (title: string) => Promise<void>;
  className?: string;
}

function formatDate(date: Date): string {
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  }).format(date);
}

function formatDateTime(date: Date): string {
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    hour: "numeric",
    minute: "2-digit",
  }).format(date);
}

function getPriorityColor(priority: TaskPriority): string {
  switch (priority) {
    case "urgent":
      return "bg-red-500";
    case "high":
      return "bg-orange-500";
    case "medium":
      return "bg-yellow-500";
    default:
      return "bg-gray-500";
  }
}

function getStatusLabel(status: TaskStatus): string {
  switch (status) {
    case "todo":
      return "To Do";
    case "in_progress":
      return "In Progress";
    case "done":
      return "Done";
    case "cancelled":
      return "Cancelled";
  }
}

const tabValues = [
  "details",
  "subtasks",
  "comments",
  "attachments",
  "activity",
] as const;

const parseTab = parseAsStringEnum([...tabValues] as string[]).withDefault(
  "details"
);

export default function TaskDetail({
  task,
  comments = [],
  attachments = [],
  activities = [],
  subtasks = [],
  onUpdate,
  onDelete,
  onCommentAdd,
  onAttachmentUpload,
  onSubtaskToggle,
  onSubtaskAdd,
  className,
}: TaskDetailProps) {
  const [activeTab, setActiveTab] = useQueryState("tab", parseTab);
  const [isEditing, setIsEditing] = useState(false);
  const [editTitle, setEditTitle] = useState(task?.title || "");
  const [editDescription, setEditDescription] = useState(
    task?.description || ""
  );
  const [commentText, setCommentText] = useState("");
  const [showDeleteDialog, setShowDeleteDialog] = useState(false);

  if (!task) {
    return (
      <Card className={cn("w-full shadow-xs", className)}>
        <CardContent className="flex items-center justify-center p-12">
          <div className="flex flex-col items-center gap-4 text-center">
            <FileText className="size-12 text-muted-foreground" />
            <p className="text-muted-foreground text-sm">
              Select a task to view details
            </p>
          </div>
        </CardContent>
      </Card>
    );
  }

  const handleSave = async () => {
    await onUpdate?.({
      title: editTitle,
      description: editDescription,
    });
    setIsEditing(false);
  };

  const handleCancel = () => {
    setEditTitle(task.title);
    setEditDescription(task.description || "");
    setIsEditing(false);
  };

  const handleCommentSubmit = async () => {
    if (!commentText.trim()) return;
    await onCommentAdd?.(commentText);
    setCommentText("");
  };

  const completedSubtasks = subtasks.filter((s) => s.completed).length;
  const subtaskProgress =
    subtasks.length > 0 ? (completedSubtasks / subtasks.length) * 100 : 0;

  return (
    <>
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <div className="flex items-start justify-between gap-4">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              {isEditing ? (
                <div className="flex flex-col gap-2">
                  <Input
                    className="w-full font-semibold text-base"
                    onChange={(e) => setEditTitle(e.target.value)}
                    value={editTitle}
                  />
                  <Textarea
                    className="min-h-[100px] resize-none"
                    onChange={(e) => setEditDescription(e.target.value)}
                    value={editDescription}
                  />
                  <div className="flex gap-2">
                    <Button onClick={handleSave} size="sm" type="button">
                      Save
                    </Button>
                    <Button
                      onClick={handleCancel}
                      size="sm"
                      type="button"
                      variant="outline"
                    >
                      Cancel
                    </Button>
                  </div>
                </div>
              ) : (
                <>
                  <CardTitle className="wrap-break-word">
                    {task.title}
                  </CardTitle>
                  {task.description && (
                    <CardDescription className="wrap-break-word">
                      {task.description}
                    </CardDescription>
                  )}
                </>
              )}
            </div>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button size="icon" type="button" variant="ghost">
                  <MoreVertical className="size-4" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={() => setIsEditing(true)}>
                  Edit
                </DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem
                  onClick={() => setShowDeleteDialog(true)}
                  variant="destructive"
                >
                  Delete
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </CardHeader>
        <CardContent>
          <div className="flex flex-col gap-6">
            <div className="flex flex-wrap items-center gap-4">
              <div className="flex items-center gap-2">
                <span className="text-muted-foreground text-sm">Status:</span>
                <Badge variant="outline">{getStatusLabel(task.status)}</Badge>
              </div>
              <div className="flex items-center gap-2">
                <span className="text-muted-foreground text-sm">Priority:</span>
                <div className="flex items-center gap-1.5">
                  <div
                    className={cn(
                      "size-2 rounded-full",
                      getPriorityColor(task.priority)
                    )}
                  />
                  <span className="text-sm capitalize">{task.priority}</span>
                </div>
              </div>
              {task.dueDate && (
                <div className="flex items-center gap-2">
                  <Calendar className="size-4 text-muted-foreground" />
                  <span className="text-muted-foreground text-sm">
                    {formatDate(task.dueDate)}
                  </span>
                </div>
              )}
            </div>

            {task.assignees && task.assignees.length > 0 && (
              <div className="flex flex-col gap-2">
                <span className="text-muted-foreground text-sm">Assignees</span>
                <div className="flex flex-wrap gap-2">
                  {task.assignees.map((assignee) => (
                    <div
                      className="flex items-center gap-2 rounded-lg border bg-card p-2"
                      key={assignee.id}
                    >
                      <Avatar className="size-6">
                        <AvatarImage
                          alt={assignee.name}
                          src={assignee.avatar}
                        />
                        <AvatarFallback>
                          {assignee.name.charAt(0).toUpperCase()}
                        </AvatarFallback>
                      </Avatar>
                      <span className="text-sm">{assignee.name}</span>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {task.tags && task.tags.length > 0 && (
              <div className="flex flex-col gap-2">
                <span className="text-muted-foreground text-sm">Tags</span>
                <div className="flex flex-wrap gap-2">
                  {task.tags.map((tag) => (
                    <Badge key={tag} variant="outline">
                      {tag}
                    </Badge>
                  ))}
                </div>
              </div>
            )}

            <Tabs
              className="flex w-full flex-col gap-4"
              onValueChange={setActiveTab}
              value={activeTab}
            >
              <TabsList>
                <TabsTrigger value="details">Details</TabsTrigger>
                <TabsTrigger value="subtasks">
                  Subtasks ({subtasks.length})
                </TabsTrigger>
                <TabsTrigger value="comments">
                  Comments ({comments.length})
                </TabsTrigger>
                <TabsTrigger value="attachments">
                  Attachments ({attachments.length})
                </TabsTrigger>
                <TabsTrigger value="activity">Activity</TabsTrigger>
              </TabsList>

              <div>
                <TabsContent value="details">
                  <div className="flex flex-col gap-4">
                    <div>
                      <span className="text-muted-foreground text-sm">
                        Created: {formatDateTime(task.createdAt)}
                      </span>
                    </div>
                    <div>
                      <span className="text-muted-foreground text-sm">
                        Updated: {formatDateTime(task.updatedAt)}
                      </span>
                    </div>
                  </div>
                </TabsContent>

                <TabsContent value="subtasks">
                  <div className="flex flex-col gap-4">
                    {subtasks.length > 0 && (
                      <div className="flex items-center gap-2">
                        <div className="flex-1 rounded-full bg-muted">
                          <div
                            className="h-2 rounded-full bg-primary transition-all"
                            style={{ width: `${subtaskProgress}%` }}
                          />
                        </div>
                        <span className="text-muted-foreground text-sm">
                          {completedSubtasks} of {subtasks.length}
                        </span>
                      </div>
                    )}
                    <div className="flex flex-col gap-2">
                      {subtasks.map((subtask) => (
                        <div
                          className="flex items-center gap-2 rounded-lg border bg-card p-2"
                          key={subtask.id}
                        >
                          <label className="flex flex-1 cursor-pointer items-center gap-2">
                            <Checkbox
                              aria-label={`Mark subtask '${subtask.title}' as complete`}
                              checked={subtask.completed}
                              className="size-4"
                              onCheckedChange={() =>
                                onSubtaskToggle?.(
                                  subtask.id,
                                  !subtask.completed
                                )
                              }
                              tabIndex={0}
                            />
                            <span
                              className={cn(
                                "flex-1 text-sm",
                                subtask.completed &&
                                  "text-muted-foreground line-through"
                              )}
                            >
                              {subtask.title}
                            </span>
                          </label>
                        </div>
                      ))}
                    </div>
                    {onSubtaskAdd && (
                      <Button
                        onClick={() => {
                          const title = prompt("Subtask title:");
                          if (title) onSubtaskAdd(title);
                        }}
                        size="sm"
                        type="button"
                        variant="outline"
                      >
                        <Plus className="size-4" />
                        Add Subtask
                      </Button>
                    )}
                  </div>
                </TabsContent>

                <TabsContent value="comments">
                  <div className="flex flex-col gap-4">
                    {comments.map((comment) => (
                      <div
                        className="flex gap-3 rounded-lg border bg-card p-3"
                        key={comment.id}
                      >
                        <Avatar className="size-8">
                          <AvatarImage
                            alt={comment.author.name}
                            src={comment.author.avatar}
                          />
                          <AvatarFallback>
                            {comment.author.name.charAt(0).toUpperCase()}
                          </AvatarFallback>
                        </Avatar>
                        <div className="flex-1">
                          <div className="flex items-center gap-2">
                            <span className="font-medium text-sm">
                              {comment.author.name}
                            </span>
                            <span className="text-muted-foreground text-xs">
                              {formatDateTime(comment.createdAt)}
                            </span>
                          </div>
                          <p className="wrap-break-word text-sm">
                            {comment.content}
                          </p>
                        </div>
                      </div>
                    ))}
                    {onCommentAdd && (
                      <div className="flex flex-col gap-2">
                        <Textarea
                          onChange={(e) => setCommentText(e.target.value)}
                          placeholder="Add a comment…"
                          value={commentText}
                        />
                        <Button
                          onClick={handleCommentSubmit}
                          size="sm"
                          type="button"
                        >
                          <MessageSquare className="size-4" />
                          Post Comment
                        </Button>
                      </div>
                    )}
                  </div>
                </TabsContent>

                <TabsContent value="attachments">
                  <div className="flex flex-col gap-2">
                    {attachments.map((attachment) => (
                      <div
                        className="flex items-center justify-between rounded-lg border bg-card p-3"
                        key={attachment.id}
                      >
                        <div className="flex items-center gap-3">
                          <Paperclip className="size-4 text-muted-foreground" />
                          <div className="flex flex-col">
                            <span className="text-sm">{attachment.name}</span>
                            <span className="text-muted-foreground text-xs">
                              {formatDateTime(attachment.uploadedAt)}
                            </span>
                          </div>
                        </div>
                        <Button size="sm" type="button" variant="outline">
                          Download
                        </Button>
                      </div>
                    ))}
                    {onAttachmentUpload && (
                      <Button
                        onClick={() => {
                          const input = document.createElement("input");
                          input.type = "file";
                          input.onchange = (e) => {
                            const file = (e.target as HTMLInputElement)
                              .files?.[0];
                            if (file) onAttachmentUpload(file);
                          };
                          input.click();
                        }}
                        size="sm"
                        type="button"
                        variant="outline"
                      >
                        <Paperclip className="size-4" />
                        Upload Attachment
                      </Button>
                    )}
                  </div>
                </TabsContent>

                <TabsContent value="activity">
                  <div className="flex flex-col gap-3">
                    {activities.map((activity) => (
                      <div className="flex gap-3" key={activity.id}>
                        <Avatar className="size-8">
                          <AvatarImage
                            alt={activity.user.name}
                            src={activity.user.avatar}
                          />
                          <AvatarFallback>
                            {activity.user.name.charAt(0).toUpperCase()}
                          </AvatarFallback>
                        </Avatar>
                        <div className="flex-1">
                          <p className="text-sm">
                            <span className="font-medium">
                              {activity.user.name}
                            </span>{" "}
                            {activity.description}
                          </p>
                          <span className="text-muted-foreground text-xs">
                            {formatDateTime(activity.timestamp)}
                          </span>
                        </div>
                      </div>
                    ))}
                  </div>
                </TabsContent>
              </div>
            </Tabs>
          </div>
        </CardContent>
      </Card>

      <Dialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Delete Task</DialogTitle>
            <DialogDescription>
              Are you sure you want to delete this task? This action cannot be
              undone.
            </DialogDescription>
          </DialogHeader>
          <div className="flex justify-end gap-2">
            <Button
              onClick={() => setShowDeleteDialog(false)}
              type="button"
              variant="outline"
            >
              Cancel
            </Button>
            <Button
              onClick={async () => {
                await onDelete?.();
                setShowDeleteDialog(false);
              }}
              type="button"
              variant="destructive"
            >
              Delete
            </Button>
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}

Similar components