import { type ReactElement, useState } from "react"; import { Building2, Calendar, ChevronDown, ChevronRight, ChevronUp, Equal, type LucideIcon, Mail, Phone, User, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { formatDateShort } from "@/lib/format-date"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type Priority = "HIGH" | "MEDIUM" | "LOW" | "NONE"; export type ActivityType = | "created" | "moved" | "edited" | "comment-added" | "label-added" | "label-removed" | "marked-done"; export interface Subtask { id: string; title: string; completed: boolean; } export interface TaskAttachment { id: string; name: string; /** File size in bytes. */ size: number; /** MIME type, e.g. "image/png". */ type: string; /** Object URL or data URL for local preview. */ url: string; } export interface ClientContact { id?: string; name: string; email?: string; phone?: string; company?: string; } export interface KanbanLabel { id: string; name: string; /** Any valid CSS color (hex, rgb, hsl). */ color: string; } export interface KanbanComment { id: string; /** Maps to a staff member id in the consuming app. */ authorId: string; body: string; createdAt: string; } export interface ActivityEntry { id: string; type: ActivityType; /** Maps to a staff member id in the consuming app. */ actorId: string; at: string; payload?: Record; } export interface KanbanTask { id: string; title: string; description?: string; assigneeName?: string; /** Prefer this over `assigneeName` when available — maps to a staff id. */ assigneeId?: string; /** Support staff assisting the assignee (advisor). */ supporterName?: string; /** ISO date string. */ startDate?: string; /** ISO date string. */ dueDate?: string; priority: Priority; /** How many days this task has been sitting in the current column. */ daysInColumn?: number; subtasks: Subtask[]; /** Long-form notes shown in the detail drawer's Notes tab. */ notes?: string; /** Label ids resolved by the consuming app via the `labels` card prop. */ labelIds?: string[]; /** Comments shown in the detail drawer's Comments tab. */ comments?: KanbanComment[]; /** Audit log shown in the detail drawer's Activity tab. */ activity?: ActivityEntry[]; /** Files and images attached to this task. */ attachments?: TaskAttachment[]; /** End-customer or lead this task is about. Rendered as a contact strip. */ clientContact?: ClientContact; /** * Staff ids watching this card (Jira-style). Watchers receive card-activity * emails. Populated automatically for the creator and assignee; anyone can * add or remove themselves via the detail drawer's Watch control. */ watcherIds?: string[]; /** Staff id of whoever created the card. Auto-added to `watcherIds`. */ createdById?: string; } export interface KanbanCardProps { task: KanbanTask; /** * Marks the card as complete (e.g. it sits in a "Done"/completion column). * Suppresses due-date urgency states — a done task is never "Overdue"/"Due * today"; its due date renders as a plain, muted date instead. */ isComplete?: boolean; /** When true, the subtasks section is shown. */ showSubtasks?: boolean; /** * Resolved label objects for display. Pass the labels that match * `task.labelIds`. The component is pure — it does not resolve ids itself. */ labels?: KanbanLabel[]; onSubtaskToggle?: (taskId: string, subtaskId: string) => void; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Priority uses Jira-style directional icons (not a color dot) so the meaning // reads as an actual priority rather than a status chip: High = up chevron, // Medium = equals, Low = down chevron. NONE renders nothing. const PRIORITY_CONFIG: Record< Priority, { label: string; color: string; icon: LucideIcon | null } > = { HIGH: { label: "High", color: "var(--color-destructive)", icon: ChevronUp }, MEDIUM: { label: "Medium", color: "var(--color-warning)", icon: Equal }, LOW: { label: "Low", color: "var(--color-success)", icon: ChevronDown }, NONE: { label: "None", color: "transparent", icon: null }, }; // --------------------------------------------------------------------------- // PriorityIcon — shared Jira-style priority glyph, reused by the card and the // task-form PrioritySelect so both surfaces stay visually consistent. // --------------------------------------------------------------------------- export interface PriorityIconProps { priority: Priority; className?: string; } export function PriorityIcon({ priority, className, }: PriorityIconProps): ReactElement | null { const { label, color, icon: Icon } = PRIORITY_CONFIG[priority]; if (!Icon) return null; return ( ); } type DueDateState = "overdue" | "today" | "soon" | "ok"; const getDueDateState = (iso: string): DueDateState => { const due = new Date(iso); const now = new Date(); const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const diff = Math.round( (dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24), ); if (diff < 0) return "overdue"; if (diff === 0) return "today"; if (diff <= 2) return "soon"; return "ok"; }; // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function LabelChips({ labels }: { labels: KanbanLabel[] }): ReactElement { return (
{labels.slice(0, 4).map((label) => ( {label.name} ))} {labels.length > 4 && ( +{labels.length - 4} )}
); } function ContactStrip({ contact }: { contact: ClientContact }): ReactElement { return (
{contact.company && ( )} {contact.phone && ( )} {contact.email && ( )}
); } function DatesRow({ startDate, dueDate, isComplete = false, }: { startDate?: string; dueDate?: string; isComplete?: boolean; }): ReactElement | null { if (!startDate && !dueDate) return null; // A complete task is never overdue/due-today — show its due date as a plain // muted date instead of an urgency state. const state = dueDate ? (isComplete ? "ok" : getDueDateState(dueDate)) : null; return (
{startDate && ( {formatDateShort(startDate)} )} {startDate && dueDate && } {dueDate && state && ( {state === "overdue" ? `Overdue · ${formatDateShort(dueDate)}` : state === "today" ? "Due today" : formatDateShort(dueDate)} )}
); } function PersonRow({ label, name, }: { label: string; name: string; }): ReactElement { return (
{label}: {name}
); } function SubtaskProgress({ subtasks }: { subtasks: Subtask[] }): ReactElement { const total = subtasks.length; const done = subtasks.filter((s) => s.completed).length; return (
{subtasks.map((s, i) => (
))}
{done}/{total}
); } function SubtaskList({ subtasks, taskId, onToggle, }: { subtasks: Subtask[]; taskId: string; onToggle?: (taskId: string, subtaskId: string) => void; }): ReactElement { const [expanded, setExpanded] = useState(false); return (
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} role="presentation" > {expanded && (
    {subtasks.map((s) => (
  • onToggle?.(taskId, s.id)} aria-label={s.title} /> {s.title}
  • ))}
)}
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function KanbanCard({ task, isComplete = false, showSubtasks = false, labels = [], onSubtaskToggle, }: KanbanCardProps): ReactElement { const totalSubtasks = task.subtasks.length; const hasSubtasks = showSubtasks && totalSubtasks > 0; const nextSubtask = task.subtasks.find((s) => !s.completed); const hasFooter = !!task.assigneeName || !!task.supporterName || !!task.startDate || !!task.dueDate || totalSubtasks > 0 || hasSubtasks; return ( {labels.length > 0 && }

{task.title}

{task.description && (

{task.description}

)} {task.clientContact && } {hasFooter &&
} {task.assigneeName && ( )} {task.supporterName && ( )} {totalSubtasks > 0 && } {hasSubtasks && nextSubtask && (

Next to complete:

{nextSubtask.title}

)} {hasSubtasks && ( )}
); } export default KanbanCard;