Files
stack/apps/web/src/components/kanban/TaskCard.tsx
Jason Woltje 1310eff96c
Some checks failed
ci/woodpecker/push/web Pipeline failed
feat(web): add admin user management components (in progress)
- InviteUserDialog.tsx
- admin.ts API client

Hit rate limit mid-flight. Continuing work.
2026-02-28 12:47:44 -06:00

164 lines
4.9 KiB
TypeScript

"use client";
import React from "react";
import type { Task } from "@mosaic/shared";
import { TaskPriority } from "@mosaic/shared";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, Flag, User } from "lucide-react";
import { format } from "date-fns";
interface TaskCardProps {
task: Task & { assignee?: { name: string; image?: string | null } };
}
const priorityConfig = {
[TaskPriority.HIGH]: {
label: "High",
className: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
},
[TaskPriority.MEDIUM]: {
label: "Medium",
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
},
[TaskPriority.LOW]: {
label: "Low",
className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
},
};
/**
* Generate initials from a name (e.g., "John Doe" -> "JD")
*/
function getInitials(name: string): string {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
/**
* Task Card component for Kanban board
*
* Displays:
* - Task title
* - Priority badge
* - Assignee avatar (if assigned)
* - Due date (if set)
*/
export function TaskCard({ task }: TaskCardProps): React.ReactElement {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isOverdue =
task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "COMPLETED";
const isDueSoon =
task.dueDate &&
!isOverdue &&
new Date(task.dueDate).getTime() - new Date().getTime() < 3 * 24 * 60 * 60 * 1000; // 3 days
const priorityInfo = priorityConfig[task.priority];
return (
<article
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
bg-white dark:bg-gray-800
rounded-lg shadow-sm border border-gray-200 dark:border-gray-700
p-4 space-y-3
cursor-grab active:cursor-grabbing
transition-all duration-200
hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? "opacity-50" : "opacity-100"}
`}
>
{/* Task Title */}
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-2">
{task.title}
</h4>
{/* Task Metadata */}
<div className="flex items-center gap-2 flex-wrap">
{/* Priority Badge */}
<span
data-priority={task.priority}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
${priorityInfo.className}
`}
>
<Flag className="w-3 h-3" aria-hidden="true" />
{priorityInfo.label}
</span>
{/* Due Date */}
{task.dueDate && (
<span
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs
${
isOverdue
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
: isDueSoon
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
}
`}
>
<Calendar className="w-3 h-3" aria-label="Due date" />
{format(new Date(task.dueDate), "MMM d")}
</span>
)}
</div>
{/* Assignee Avatar */}
{task.assignee && (
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{task.assignee.image ? (
<img
src={task.assignee.image}
alt={task.assignee.name}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<div
className="w-6 h-6 rounded-full bg-indigo-500 text-white flex items-center justify-center text-xs font-medium"
aria-label={`Assigned to ${task.assignee.name}`}
>
{getInitials(task.assignee.name)}
</div>
)}
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{task.assignee.name}
</span>
</div>
)}
{/* Fallback for unassigned tasks */}
{!task.assignee && task.assigneeId && (
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
<div
className="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center"
aria-label="Assigned user"
>
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</div>
<span className="text-xs text-gray-500 dark:text-gray-500">Assigned</span>
</div>
)}
</article>
);
}