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:
@@ -242,8 +242,8 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 13,
|
"priority": 13,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Completed: Project Detail view with Breadcrumb (Client > Sub-Client path from clients list), H1 project name, 3 stat cards (Notes count, Tasks Complete done/total, Checkpoints approved/total), AI Project Summary card with sparkles icon showing aiSummary or placeholder text. All data fetched via tRPC queries scoped to projectId. shadcn/ui breadcrumb installed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-014",
|
"id": "US-014",
|
||||||
|
|||||||
@@ -230,13 +230,37 @@ const tasksRouter = router({
|
|||||||
.all();
|
.all();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listAssignees: publicProcedure.query(() => {
|
||||||
|
const rows = getDb()
|
||||||
|
.select({ assignee: tasks.assignee })
|
||||||
|
.from(tasks)
|
||||||
|
.all();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.assignee) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.assignee) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
for (const n of parsed) {
|
||||||
|
if (typeof n === 'string' && n) names.add(n);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
names.add(row.assignee);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
names.add(row.assignee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names].sort();
|
||||||
|
}),
|
||||||
|
|
||||||
create: publicProcedure
|
create: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
priority: z.string().optional(),
|
priority: z.string().optional(),
|
||||||
assignee: z.string().optional(),
|
assignees: z.array(z.string()).optional(),
|
||||||
dueDate: z.number().optional(),
|
dueDate: z.number().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
@@ -249,7 +273,7 @@ const tasksRouter = router({
|
|||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
status: input.status ?? 'todo',
|
status: input.status ?? 'todo',
|
||||||
priority: input.priority ?? 'medium',
|
priority: input.priority ?? 'medium',
|
||||||
assignee: input.assignee ?? null,
|
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
|
||||||
dueDate: input.dueDate ?? null,
|
dueDate: input.dueDate ?? null,
|
||||||
projectId: input.projectId ?? null,
|
projectId: input.projectId ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -264,7 +288,7 @@ const tasksRouter = router({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
priority: z.string().optional(),
|
priority: z.string().optional(),
|
||||||
assignee: z.string().optional(),
|
assignees: z.array(z.string()).optional(),
|
||||||
dueDate: z.number().optional(),
|
dueDate: z.number().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
@@ -282,7 +306,7 @@ const tasksRouter = router({
|
|||||||
if (input.description !== undefined) set.description = input.description;
|
if (input.description !== undefined) set.description = input.description;
|
||||||
if (input.status !== undefined) set.status = input.status;
|
if (input.status !== undefined) set.status = input.status;
|
||||||
if (input.priority !== undefined) set.priority = input.priority;
|
if (input.priority !== undefined) set.priority = input.priority;
|
||||||
if (input.assignee !== undefined) set.assignee = input.assignee;
|
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
|
||||||
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
||||||
if (input.projectId !== undefined) set.projectId = input.projectId;
|
if (input.projectId !== undefined) set.projectId = input.projectId;
|
||||||
if (Object.keys(set).length > 0) {
|
if (Object.keys(set).length > 0) {
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Sparkles, FileText, CheckCircle2, Milestone } from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@/components/ui/breadcrumb';
|
||||||
|
|
||||||
type ProjectDetailProps = {
|
type ProjectDetailProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -6,6 +15,41 @@ type ProjectDetailProps = {
|
|||||||
|
|
||||||
export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||||
|
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||||
|
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
|
||||||
|
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||||
|
const { data: checkpointsList } = trpc.checkpoints.list.useQuery({ projectId });
|
||||||
|
|
||||||
|
// Build breadcrumb path: Client > Sub-Client
|
||||||
|
const breadcrumbPath = useMemo(() => {
|
||||||
|
if (!project?.clientId || !clientsList) return [];
|
||||||
|
|
||||||
|
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
|
||||||
|
const client = clientMap.get(project.clientId);
|
||||||
|
if (!client) return [];
|
||||||
|
|
||||||
|
// If client has a parent, show parent > client
|
||||||
|
if (client.parentId) {
|
||||||
|
const parent = clientMap.get(client.parentId);
|
||||||
|
if (parent) return [parent.name, client.name];
|
||||||
|
}
|
||||||
|
return [client.name];
|
||||||
|
}, [project?.clientId, clientsList]);
|
||||||
|
|
||||||
|
// Compute stats
|
||||||
|
const notesCount = notesList?.length ?? 0;
|
||||||
|
|
||||||
|
const taskStats = useMemo(() => {
|
||||||
|
const all = tasksList ?? [];
|
||||||
|
const done = all.filter((t) => t.status === 'done').length;
|
||||||
|
return { done, total: all.length };
|
||||||
|
}, [tasksList]);
|
||||||
|
|
||||||
|
const checkpointStats = useMemo(() => {
|
||||||
|
const all = checkpointsList ?? [];
|
||||||
|
const approved = all.filter((c) => c.isApproved === 1).length;
|
||||||
|
return { approved, total: all.length };
|
||||||
|
}, [checkpointsList]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -24,11 +68,81 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto flex flex-col gap-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
{breadcrumbPath.length > 0 && (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{breadcrumbPath.map((segment, i) => (
|
||||||
|
<BreadcrumbItem key={i}>
|
||||||
|
{i > 0 && <BreadcrumbSeparator />}
|
||||||
|
<span className="text-muted-foreground">{segment}</span>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Name */}
|
||||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Project detail view will be implemented in US-013.
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card className="py-4">
|
||||||
|
<CardHeader className="pb-0 pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Notes</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="text-2xl font-semibold">{notesCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-4">
|
||||||
|
<CardHeader className="pb-0 pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Tasks Complete</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{taskStats.done}/{taskStats.total}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-4">
|
||||||
|
<CardHeader className="pb-0 pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Milestone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Checkpoints</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{checkpointStats.approved}/{checkpointStats.total}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Project Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-sm font-medium">AI Project Summary</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.aiSummary || 'AI summary will appear here'}
|
||||||
</p>
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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 { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -21,8 +21,12 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const NO_CLIENT = '__no_client__';
|
||||||
|
|
||||||
interface NewTaskDialogProps {
|
interface NewTaskDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -36,12 +40,48 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
const [priority, setPriority] = useState('medium');
|
const [priority, setPriority] = useState('medium');
|
||||||
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||||
|
const [dueTime, setDueTime] = useState('');
|
||||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
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: projectsList } = trpc.projects.listAll.useQuery();
|
||||||
|
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||||
|
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||||
const utils = trpc.useUtils();
|
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({
|
const createTask = trpc.tasks.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void utils.tasks.list.invalidate();
|
void utils.tasks.list.invalidate();
|
||||||
@@ -55,29 +95,119 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
setPriority('medium');
|
setPriority('medium');
|
||||||
setStatus(defaultStatus ?? 'todo');
|
setStatus(defaultStatus ?? 'todo');
|
||||||
setDueDate(undefined);
|
setDueDate(undefined);
|
||||||
|
setDueTime('');
|
||||||
setProjectId(defaultProjectId ?? '');
|
setProjectId(defaultProjectId ?? '');
|
||||||
setAssignee('');
|
setAssignees([]);
|
||||||
|
setAssigneeInput('');
|
||||||
|
setAssigneePopoverOpen(false);
|
||||||
|
resetProjectCreation();
|
||||||
onOpenChange(false);
|
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();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
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({
|
createTask.mutate({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
priority,
|
priority,
|
||||||
status,
|
status,
|
||||||
dueDate: dueDate ? dueDate.getTime() : undefined,
|
dueDate: resolvedDueDate,
|
||||||
projectId: projectId || undefined,
|
projectId: resolvedProjectId,
|
||||||
assignee: assignee.trim() || undefined,
|
assignees: assignees.length ? assignees : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSubmitting =
|
||||||
|
createTask.isPending ||
|
||||||
|
createClientMutation.isPending ||
|
||||||
|
createProjectMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Task</DialogTitle>
|
<DialogTitle>New Task</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -99,10 +229,9 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
className="min-h-20"
|
className="min-h-20"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Priority + Status row */}
|
{/* Priority */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Select value={priority} onValueChange={setPriority}>
|
<Select value={priority} onValueChange={setPriority}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Priority" />
|
<SelectValue placeholder="Priority" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -112,8 +241,9 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
<Select value={status} onValueChange={setStatus}>
|
<Select value={status} onValueChange={setStatus}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -122,9 +252,9 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
<SelectItem value="done">Completed</SelectItem>
|
<SelectItem value="done">Completed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date + Time */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -136,7 +266,9 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'}
|
{dueDate
|
||||||
|
? format(dueDate, dueTime ? 'PPP' : 'PPP')
|
||||||
|
: 'Pick a due date'}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
@@ -145,16 +277,36 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
selected={dueDate}
|
selected={dueDate}
|
||||||
onSelect={setDueDate}
|
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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{dueDate && dueTime && (
|
||||||
|
<p className="text-xs text-muted-foreground pl-1">
|
||||||
|
Due: {format(dueDate, 'PPP')} at {dueTime}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project */}
|
{/* Project */}
|
||||||
<Select value={projectId} onValueChange={setProjectId}>
|
{!creatingProject ? (
|
||||||
<SelectTrigger>
|
<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)" />
|
<SelectValue placeholder="Project (optional)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">No project</SelectItem>
|
<SelectItem value="none">No project</SelectItem>
|
||||||
{projectsList?.map((p) => (
|
{projectsList?.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{p.name}
|
{p.name}
|
||||||
@@ -162,20 +314,261 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
|
|
||||||
{/* Assignee */}
|
{/* Project name */}
|
||||||
<Input
|
<Input
|
||||||
placeholder="Assignee (optional)"
|
placeholder="Project name"
|
||||||
value={assignee}
|
value={newProjectName}
|
||||||
onChange={(e) => setAssignee(e.target.value)}
|
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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={resetAndClose}>
|
<Button type="button" variant="outline" onClick={resetAndClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!title.trim() || createTask.isPending}>
|
<Button type="submit" disabled={!title.trim() || isSubmitting}>
|
||||||
Create Task
|
{isSubmitting ? 'Creating…' : 'Create Task'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/renderer/components/ui/breadcrumb.tsx
Normal file
109
src/renderer/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CheckIcon } from "lucide-react"
|
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -10,11 +10,12 @@ function Checkbox({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
const isIndeterminate = props.checked === 'indeterminate';
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,7 +24,7 @@ function Checkbox({
|
|||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="grid place-content-center text-current transition-none"
|
className="grid place-content-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-3.5" />
|
{isIndeterminate ? <MinusIcon className="size-3.5" /> : <CheckIcon className="size-3.5" />}
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
)
|
||||||
|
|||||||
168
src/renderer/components/ui/input-group.tsx
Normal file
168
src/renderer/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
193
src/renderer/components/ui/item.tsx
Normal file
193
src/renderer/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn("group/item-group flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "p-4 gap-4 ",
|
||||||
|
sm: "py-3 px-4 gap-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "div"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "size-8 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
@@ -5,29 +5,25 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ArrowUp,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowDown,
|
|
||||||
Calendar,
|
|
||||||
ChevronRight,
|
|
||||||
User,
|
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||||
import { Input } from '@/components/ui/input';
|
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
Select,
|
||||||
DropdownMenuContent,
|
SelectContent,
|
||||||
DropdownMenuItem,
|
SelectItem,
|
||||||
DropdownMenuTrigger,
|
SelectTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
|
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||||
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||||
|
|
||||||
export const Route = createFileRoute('/tasks')({
|
export const Route = createFileRoute('/tasks')({
|
||||||
component: TasksPage,
|
component: TasksPage,
|
||||||
@@ -42,18 +38,13 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
createdAt: 'Created Date',
|
createdAt: 'Created Date',
|
||||||
};
|
};
|
||||||
|
|
||||||
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()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
|
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||||
|
|
||||||
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
||||||
|
|
||||||
@@ -85,6 +76,12 @@ function TasksPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteTask = trpc.tasks.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.tasks.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const tasksList = filteredTasks ?? [];
|
const tasksList = filteredTasks ?? [];
|
||||||
|
|
||||||
// Compute stats from all tasks (unfiltered)
|
// Compute stats from all tasks (unfiltered)
|
||||||
@@ -100,10 +97,10 @@ function TasksPage() {
|
|||||||
|
|
||||||
const handleCheckboxToggle = useCallback(
|
const handleCheckboxToggle = useCallback(
|
||||||
(taskId: string, currentStatus: string | null) => {
|
(taskId: string, currentStatus: string | null) => {
|
||||||
updateTask.mutate({
|
const nextStatus =
|
||||||
id: taskId,
|
currentStatus === 'todo' ? 'in_progress' :
|
||||||
status: currentStatus === 'done' ? 'todo' : 'done',
|
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||||
});
|
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||||
},
|
},
|
||||||
[updateTask],
|
[updateTask],
|
||||||
);
|
);
|
||||||
@@ -112,40 +109,72 @@ function TasksPage() {
|
|||||||
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
|
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<StatCard icon={<ClipboardCheck className="h-5 w-5 text-muted-foreground" />} label="Total Tasks" value={stats.total} />
|
<Item variant="muted">
|
||||||
<StatCard icon={<ListTodo className="h-5 w-5 text-blue-500" />} label="To Do" value={stats.todo} />
|
<ItemMedia variant="icon">
|
||||||
<StatCard icon={<Loader2 className="h-5 w-5 text-yellow-500" />} label="In Progress" value={stats.inProgress} />
|
<ClipboardCheck />
|
||||||
<StatCard icon={<CheckCircle2 className="h-5 w-5 text-green-500" />} label="Completed" value={stats.completed} />
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{stats.total}</ItemTitle>
|
||||||
|
<ItemDescription>Total Tasks</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<ListTodo />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{stats.todo}</ItemTitle>
|
||||||
|
<ItemDescription>To Do</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
<Item variant="muted" className="bg-sky-50">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<Loader2 />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{stats.inProgress}</ItemTitle>
|
||||||
|
<ItemDescription>In Progress</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
<Item variant="muted" className="bg-green-50">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<CheckCircle2 />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{stats.completed}</ItemTitle>
|
||||||
|
<ItemDescription>Completed</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Order By */}
|
{/* Search + Order By */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative flex-1">
|
<InputGroup className="flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<InputGroupAddon>
|
||||||
<Input
|
<Search />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
placeholder="Search tasks or projects..."
|
placeholder="Search tasks or projects..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
className="pl-9"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</InputGroup>
|
||||||
<DropdownMenu>
|
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||||
<DropdownMenuTrigger asChild>
|
<SelectTrigger className="w-[180px]">
|
||||||
<Button variant="outline" size="sm">
|
<SelectValue placeholder="Order by" />
|
||||||
Order by: {ORDER_LABELS[orderBy]}
|
</SelectTrigger>
|
||||||
</Button>
|
<SelectContent>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||||
<DropdownMenuItem key={key} onClick={() => setOrderBy(key)}>
|
<SelectItem key={key} value={key}>
|
||||||
{label}
|
{label}
|
||||||
</DropdownMenuItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</SelectContent>
|
||||||
</DropdownMenu>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter Tabs */}
|
{/* Status Filter Tabs + New Task Button */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All</TabsTrigger>
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
@@ -154,9 +183,6 @@ function TasksPage() {
|
|||||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* New Task Button */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
New Task
|
New Task
|
||||||
@@ -166,160 +192,37 @@ function TasksPage() {
|
|||||||
{/* Task List */}
|
{/* Task List */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{tasksList.length === 0 ? (
|
{tasksList.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground text-sm py-12">
|
<Empty>
|
||||||
No tasks found.
|
<EmptyHeader>
|
||||||
</div>
|
<EmptyMedia variant="icon">
|
||||||
|
<ClipboardCheck />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No tasks found</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Create a new task to get started or adjust your filters.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
tasksList.map((task) => (
|
tasksList.map((task) => (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onToggle={handleCheckboxToggle}
|
onToggle={handleCheckboxToggle}
|
||||||
|
onEdit={setEditTask}
|
||||||
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
</div>
|
<EditTaskDialog
|
||||||
);
|
task={editTask}
|
||||||
}
|
open={!!editTask}
|
||||||
|
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
||||||
function StatCard({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="py-4">
|
|
||||||
<CardHeader className="pb-0 pt-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{icon}
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="text-2xl font-semibold">{value}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TaskItem = {
|
|
||||||
id: string;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PriorityBadge({ priority }: { priority: string | null }) {
|
|
||||||
switch (priority) {
|
|
||||||
case 'high':
|
|
||||||
return (
|
|
||||||
<Badge variant="destructive" className="text-xs gap-1">
|
|
||||||
<ArrowUp className="h-3 w-3" />
|
|
||||||
HIGH
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case 'medium':
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary" className="text-xs gap-1">
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
MEDIUM
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case 'low':
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-xs gap-1 text-green-600 border-green-300">
|
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
LOW
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskRow({
|
|
||||||
task,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
task: TaskItem;
|
|
||||||
onToggle: (id: string, status: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const isDone = task.status === 'done';
|
|
||||||
|
|
||||||
const breadcrumb: string[] = [];
|
|
||||||
if (task.clientName) breadcrumb.push(task.clientName);
|
|
||||||
if (task.subClientName) breadcrumb.push(task.subClientName);
|
|
||||||
if (task.projectName) breadcrumb.push(task.projectName);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-4 px-4 py-3 rounded-md border ${
|
|
||||||
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Checkbox */}
|
|
||||||
<Checkbox
|
|
||||||
checked={isDone}
|
|
||||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Title + Description */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Priority Chip */}
|
|
||||||
<PriorityBadge priority={task.priority} />
|
|
||||||
|
|
||||||
{/* Due Date Chip */}
|
|
||||||
{task.dueDate && (
|
|
||||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{formatDueDate(task.dueDate)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
{breadcrumb.length > 0 && (
|
|
||||||
<div className="hidden lg:flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
|
||||||
{breadcrumb.map((part, i) => (
|
|
||||||
<span key={i} className="flex items-center gap-1">
|
|
||||||
{i > 0 && <ChevronRight className="h-3 w-3" />}
|
|
||||||
{part}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assignee */}
|
|
||||||
{task.assignee && (
|
|
||||||
<div className="hidden md:flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
{task.assignee}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user