feat: add PriorityBadge component and integrate into TaskRow
- Implemented PriorityBadge component to display task priority with icons. - Created TaskRow component to represent individual tasks with metadata. - Added breadcrumb navigation for task hierarchy. - Enhanced checkbox component to support indeterminate state. - Introduced InputGroup for better input handling in task search. - Updated tasks route to utilize new components and improve UI. - Added empty state representation for task list.
This commit is contained in:
340
src/renderer/components/tasks/EditTaskDialog.tsx
Normal file
340
src/renderer/components/tasks/EditTaskDialog.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskItem } from './TaskRow';
|
||||
|
||||
function parseAssigneesLocal(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
|
||||
} catch { /* plain string fallback */ }
|
||||
return [raw];
|
||||
}
|
||||
|
||||
interface EditTaskDialogProps {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState('todo');
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [dueTime, setDueTime] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Pre-fill fields whenever the task changes
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
setTitle(task.title);
|
||||
setDescription(task.description ?? '');
|
||||
setPriority(task.priority ?? 'medium');
|
||||
setStatus(task.status ?? 'todo');
|
||||
if (task.dueDate) {
|
||||
const d = new Date(task.dueDate);
|
||||
setDueDate(d);
|
||||
setDueTime(
|
||||
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
||||
);
|
||||
} else {
|
||||
setDueDate(undefined);
|
||||
setDueTime('');
|
||||
}
|
||||
setProjectId(task.projectId ?? '');
|
||||
setAssignees(parseAssigneesLocal(task.assignee));
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
}, [task]);
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!task || !title.trim()) return;
|
||||
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const d = new Date(dueDate);
|
||||
if (dueTime) {
|
||||
const parts = dueTime.split(':');
|
||||
const h = parseInt(parts[0] ?? '0', 10);
|
||||
const m = parseInt(parts[1] ?? '0', 10);
|
||||
d.setHours(h, m, 0, 0);
|
||||
}
|
||||
resolvedDueDate = d.getTime();
|
||||
}
|
||||
|
||||
updateTask.mutate({
|
||||
id: task.id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: projectId || undefined,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
/>
|
||||
<div className="border-t px-3 py-2">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueTime && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{knownAssignees.length > 0 && (
|
||||
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || updateTask.isPending}>
|
||||
{updateTask.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -21,8 +21,12 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
|
||||
interface NewTaskDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -36,12 +40,48 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [dueTime, setDueTime] = useState('');
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [assignee, setAssignee] = useState('');
|
||||
|
||||
// Multi-assignee state
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Inline project creation state
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
|
||||
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
|
||||
const [creatingClient, setCreatingClient] = useState(false);
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [creatingSubClient, setCreatingSubClient] = useState(false);
|
||||
const [newSubClientName, setNewSubClientName] = useState('');
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
||||
const subClientsByParent = useMemo(() => {
|
||||
const m = new Map<string, typeof clientList>();
|
||||
for (const c of clientList) {
|
||||
if (c.parentId) {
|
||||
const arr = m.get(c.parentId) ?? [];
|
||||
arr.push(c);
|
||||
m.set(c.parentId, arr);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const createClientMutation = trpc.clients.create.useMutation({
|
||||
onSuccess: () => void utils.clients.list.invalidate(),
|
||||
});
|
||||
const createProjectMutation = trpc.projects.create.useMutation({
|
||||
onSuccess: () => void utils.projects.listAll.invalidate(),
|
||||
});
|
||||
const createTask = trpc.tasks.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
@@ -55,29 +95,119 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
setPriority('medium');
|
||||
setStatus(defaultStatus ?? 'todo');
|
||||
setDueDate(undefined);
|
||||
setDueTime('');
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAssignee('');
|
||||
setAssignees([]);
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
resetProjectCreation();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
function resetProjectCreation() {
|
||||
setCreatingProject(false);
|
||||
setNewProjectName('');
|
||||
setNewProjectClientId(NO_CLIENT);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
async function handleCreateInlineProject(): Promise<string | undefined> {
|
||||
let resolvedClientId: string | undefined;
|
||||
|
||||
if (creatingClient && newClientName.trim()) {
|
||||
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
|
||||
resolvedClientId = r.id;
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: resolvedClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
}
|
||||
} else if (newProjectClientId !== NO_CLIENT) {
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: newProjectClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
} else if (newProjectSubClientId !== NO_CLIENT) {
|
||||
resolvedClientId = newProjectSubClientId;
|
||||
} else {
|
||||
resolvedClientId = newProjectClientId;
|
||||
}
|
||||
}
|
||||
|
||||
const r = await createProjectMutation.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
clientId: resolvedClientId,
|
||||
});
|
||||
return r.id;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
// Resolve dueDate + optional time
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const d = new Date(dueDate);
|
||||
if (dueTime) {
|
||||
const parts = dueTime.split(':');
|
||||
const h = parseInt(parts[0] ?? '0', 10);
|
||||
const m = parseInt(parts[1] ?? '0', 10);
|
||||
d.setHours(h, m, 0, 0);
|
||||
}
|
||||
resolvedDueDate = d.getTime();
|
||||
}
|
||||
|
||||
// If creating a new project inline, do that first
|
||||
let resolvedProjectId = projectId || undefined;
|
||||
if (creatingProject && newProjectName.trim()) {
|
||||
resolvedProjectId = await handleCreateInlineProject();
|
||||
}
|
||||
|
||||
createTask.mutate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: dueDate ? dueDate.getTime() : undefined,
|
||||
projectId: projectId || undefined,
|
||||
assignee: assignee.trim() || undefined,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: resolvedProjectId,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createTask.isPending ||
|
||||
createClientMutation.isPending ||
|
||||
createProjectMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -99,83 +229,346 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority + Status row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Project */}
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Assignee */}
|
||||
<Input
|
||||
placeholder="Assignee (optional)"
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
/>
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? format(dueDate, dueTime ? 'PPP' : 'PPP')
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={setDueDate}
|
||||
/>
|
||||
<div className="border-t px-3 py-2">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueTime && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{!creatingProject ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingProject(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">New Project</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetProjectCreation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project name */}
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Client selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New client name"
|
||||
value={newClientName}
|
||||
onChange={(e) => setNewClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectClientId}
|
||||
onValueChange={(v) => {
|
||||
setNewProjectClientId(v);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None (Internal)</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-client selection — only when a client is selected or being created */}
|
||||
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Sub-client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingSubClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New sub-client name"
|
||||
value={newSubClientName}
|
||||
onChange={(e) => setNewSubClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : creatingClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectSubClientId}
|
||||
onValueChange={setNewProjectSubClientId}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a sub-client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None</SelectItem>
|
||||
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
|
||||
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Selected assignee badges */}
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignee picker popover */}
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{/* Known assignees list */}
|
||||
{knownAssignees.length > 0 && (
|
||||
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
{/* Add new assignee */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || createTask.isPending}>
|
||||
Create Task
|
||||
<Button type="submit" disabled={!title.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating…' : 'Create Task'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
29
src/renderer/components/tasks/PriorityBadge.tsx
Normal file
29
src/renderer/components/tasks/PriorityBadge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ArrowUp, ArrowRight, ArrowDown } from 'lucide-react';
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: string | null }) {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
High
|
||||
</span>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Medium
|
||||
</span>
|
||||
);
|
||||
case 'low':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Low
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
153
src/renderer/components/tasks/TaskRow.tsx
Normal file
153
src/renderer/components/tasks/TaskRow.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
projectName: string | null;
|
||||
clientName: string | null;
|
||||
subClientName: string | null;
|
||||
};
|
||||
|
||||
export function parseAssignees(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
|
||||
} catch { /* plain string fallback */ }
|
||||
return [raw];
|
||||
}
|
||||
|
||||
function formatDueDate(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `Due ${months[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
export function TaskRow({
|
||||
task,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
onToggle: (id: string, status: string | null) => void;
|
||||
onEdit?: (task: TaskItem) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const isDone = task.status === 'done';
|
||||
|
||||
const checkboxState: boolean | 'indeterminate' =
|
||||
task.status === 'done' ? true :
|
||||
task.status === 'in_progress' ? 'indeterminate' : false;
|
||||
|
||||
const breadcrumb: string[] = [];
|
||||
if (task.clientName) breadcrumb.push(task.clientName);
|
||||
if (task.subClientName) breadcrumb.push(task.subClientName);
|
||||
if (task.projectName) breadcrumb.push(task.projectName);
|
||||
|
||||
const hasMetadata =
|
||||
task.priority ||
|
||||
task.dueDate ||
|
||||
breadcrumb.length > 0 ||
|
||||
task.assignee;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border cursor-default select-none ${
|
||||
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
|
||||
}`}
|
||||
>
|
||||
{/* Row 1: checkbox + title + description */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checkboxState}
|
||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{task.title}
|
||||
</div>
|
||||
{task.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: metadata, indented to align with title text */}
|
||||
{hasMetadata && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-7">
|
||||
<PriorityBadge priority={task.priority} />
|
||||
|
||||
{task.dueDate && (
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDueDate(task.dueDate)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{breadcrumb.length > 0 && (
|
||||
<Breadcrumb className="shrink-0">
|
||||
<BreadcrumbList>
|
||||
{breadcrumb.map((part, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<span className="text-xs">{part}</span>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
|
||||
{task.assignee && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<User className="h-3 w-3" />
|
||||
{parseAssignees(task.assignee).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onEdit?.(task)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Task
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onDelete?.(task.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user