feat: US-011 — Global Tasks view UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
45
package-lock.json
generated
45
package-lock.json
generated
@@ -19,6 +19,7 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^9.13.2",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -608,6 +610,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@date-fns/tz": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
|
||||||
@@ -8728,6 +8736,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns-jalali": {
|
||||||
|
"version": "4.1.0-0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||||
|
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debounce-fn": {
|
"node_modules/debounce-fn": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
|
||||||
@@ -15906,6 +15930,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-day-picker": {
|
||||||
|
"version": "9.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz",
|
||||||
|
"integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@date-fns/tz": "^1.4.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-jalali": "^4.1.0-0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/gpbl"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^9.13.2",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
@@ -202,8 +202,8 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 11,
|
"priority": 11,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Completed: Global Tasks view with 4 stat cards (Card, CardHeader, CardTitle, CardContent), search with 300ms debounce (Input + Search icon), status filter tabs (Tabs/TabsList/TabsTrigger: All/To Do/In Progress/Completed), Order by dropdown (DropdownMenu: Due Date/Priority/Created Date), task rows with Checkbox toggle (todo↔done), priority Badge (destructive/secondary/outline variants), due date chip, breadcrumb (Client > Sub-Client > Project), assignee. Completed rows green-tinted. NewTaskDialog component with title Input, Textarea description, Select priority/status, Popover+Calendar due date, Select project, Input assignee. All shadcn/ui primitives installed: card, tabs, checkbox, badge, textarea, popover, calendar."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-012",
|
"id": "US-012",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
|
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
|
||||||
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
|
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
|
||||||
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
|
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
|
||||||
|
- NewTaskDialog component at `src/renderer/components/tasks/NewTaskDialog.tsx` accepts `defaultProjectId` and `defaultStatus` props for reuse in Kanban column "+ Add" buttons
|
||||||
|
- `date-fns` is available as a transitive dependency of `react-day-picker` (shadcn/ui calendar)
|
||||||
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
|
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,3 +183,23 @@
|
|||||||
- `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign)
|
- `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign)
|
||||||
- Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand
|
- Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-20 - US-011
|
||||||
|
- What was implemented:
|
||||||
|
- Full Global Tasks view UI at `/tasks` route
|
||||||
|
- 4 stat cards (Total Tasks, To Do, In Progress, Completed) using shadcn/ui Card components with Lucide icons, reactively updated from unfiltered `tasks.list` query
|
||||||
|
- Search bar with 300ms debounce using shadcn/ui Input + Search icon
|
||||||
|
- Status filter tabs using shadcn/ui Tabs (All | To Do | In Progress | Completed)
|
||||||
|
- "Order by" dropdown using shadcn/ui DropdownMenu (Due Date | Priority | Created Date)
|
||||||
|
- Task rows with: shadcn/ui Checkbox (toggles todo↔done), title (bold 14px), description (muted truncated), priority Badge (HIGH=destructive, MEDIUM=secondary, LOW=outline green), due date chip (calendar icon + formatted date), breadcrumb (Client > Sub-Client > Project with ChevronRight separators), assignee (User icon + name)
|
||||||
|
- Completed task rows have green-tinted background (`bg-green-50 border-green-200`)
|
||||||
|
- NewTaskDialog component with: Input for title (required), Textarea for description, Select for priority/status, Popover+Calendar for due date, Select for project (from `projects.listAll`), Input for assignee
|
||||||
|
- Installed shadcn/ui components: card, tabs, checkbox, badge, textarea, popover, calendar
|
||||||
|
- Files changed: `src/renderer/routes/tasks.tsx`, `src/renderer/components/tasks/NewTaskDialog.tsx` (new), `src/renderer/components/ui/card.tsx` (new), `src/renderer/components/ui/tabs.tsx` (new), `src/renderer/components/ui/checkbox.tsx` (new), `src/renderer/components/ui/badge.tsx` (new), `src/renderer/components/ui/textarea.tsx` (new), `src/renderer/components/ui/popover.tsx` (new), `src/renderer/components/ui/calendar.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use two separate `tasks.list` queries: one unfiltered `{}` for stat card counts, one with filters for the displayed list — ensures stats always reflect total counts
|
||||||
|
- `date-fns` `format(date, 'PPP')` produces "February 20th, 2026" style dates — already installed as a dependency of react-day-picker (shadcn/ui calendar)
|
||||||
|
- shadcn/ui Select with an empty string value (`<SelectItem value="">No project</SelectItem>`) works as a "none" option for optional fields
|
||||||
|
- The Popover+Calendar date picker pattern is standard shadcn/ui: Popover wraps a Button trigger showing the formatted date, PopoverContent contains the Calendar
|
||||||
|
- Electron app runs at `http://localhost:5173` in dev mode but only within the Electron BrowserWindow — Playwright browser testing requires the Electron-specific test harness, not direct URL navigation
|
||||||
|
---
|
||||||
|
|||||||
185
src/renderer/components/tasks/NewTaskDialog.tsx
Normal file
185
src/renderer/components/tasks/NewTaskDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Calendar as CalendarIcon } 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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface NewTaskDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
defaultProjectId?: string;
|
||||||
|
defaultStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: NewTaskDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [priority, setPriority] = useState('medium');
|
||||||
|
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||||
|
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||||
|
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||||
|
const [assignee, setAssignee] = useState('');
|
||||||
|
|
||||||
|
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const createTask = trpc.tasks.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.tasks.list.invalidate();
|
||||||
|
resetAndClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetAndClose() {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setPriority('medium');
|
||||||
|
setStatus(defaultStatus ?? 'todo');
|
||||||
|
setDueDate(undefined);
|
||||||
|
setProjectId(defaultProjectId ?? '');
|
||||||
|
setAssignee('');
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
createTask.mutate({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
dueDate: dueDate ? dueDate.getTime() : undefined,
|
||||||
|
projectId: projectId || undefined,
|
||||||
|
assignee: assignee.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New 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 + 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)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">No project</SelectItem>
|
||||||
|
{projectsList?.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<Input
|
||||||
|
placeholder="Assignee (optional)"
|
||||||
|
value={assignee}
|
||||||
|
onChange={(e) => setAssignee(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={resetAndClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!title.trim() || createTask.isPending}>
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/renderer/components/ui/badge.tsx
Normal file
48
src/renderer/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
220
src/renderer/components/ui/calendar.tsx
Normal file
220
src/renderer/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
92
src/renderer/components/ui/card.tsx
Normal file
92
src/renderer/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
src/renderer/components/ui/checkbox.tsx
Normal file
32
src/renderer/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
87
src/renderer/components/ui/popover.tsx
Normal file
87
src/renderer/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
}
|
||||||
89
src/renderer/components/ui/tabs.tsx
Normal file
89
src/renderer/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||||
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
src/renderer/components/ui/textarea.tsx
Normal file
18
src/renderer/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -1,13 +1,325 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
ListTodo,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowDown,
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
User,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
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 {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
|
|
||||||
export const Route = createFileRoute('/tasks')({
|
export const Route = createFileRoute('/tasks')({
|
||||||
component: TasksPage,
|
component: TasksPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'todo' | 'in_progress' | 'done';
|
||||||
|
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
|
||||||
|
|
||||||
|
const ORDER_LABELS: Record<OrderBy, string> = {
|
||||||
|
dueDate: 'Due Date',
|
||||||
|
priority: 'Priority',
|
||||||
|
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 [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
if (debounceTimer.id) clearTimeout(debounceTimer.id);
|
||||||
|
debounceTimer.id = setTimeout(() => setDebouncedSearch(value), 300);
|
||||||
|
},
|
||||||
|
[debounceTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryInput = useMemo(
|
||||||
|
() => ({
|
||||||
|
...(statusFilter !== 'all' ? { status: statusFilter as 'todo' | 'in_progress' | 'done' } : {}),
|
||||||
|
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||||
|
orderBy,
|
||||||
|
}),
|
||||||
|
[statusFilter, debouncedSearch, orderBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: allTasks } = trpc.tasks.list.useQuery({});
|
||||||
|
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const updateTask = trpc.tasks.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.tasks.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasksList = filteredTasks ?? [];
|
||||||
|
|
||||||
|
// Compute stats from all tasks (unfiltered)
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const all = allTasks ?? [];
|
||||||
|
return {
|
||||||
|
total: all.length,
|
||||||
|
todo: all.filter((t) => t.status === 'todo').length,
|
||||||
|
inProgress: all.filter((t) => t.status === 'in_progress').length,
|
||||||
|
completed: all.filter((t) => t.status === 'done').length,
|
||||||
|
};
|
||||||
|
}, [allTasks]);
|
||||||
|
|
||||||
|
const handleCheckboxToggle = useCallback(
|
||||||
|
(taskId: string, currentStatus: string | null) => {
|
||||||
|
updateTask.mutate({
|
||||||
|
id: taskId,
|
||||||
|
status: currentStatus === 'done' ? 'todo' : 'done',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateTask],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
|
||||||
Tasks — coming in US-007
|
{/* Stat Cards */}
|
||||||
|
<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} />
|
||||||
|
<StatCard icon={<ListTodo className="h-5 w-5 text-blue-500" />} label="To Do" value={stats.todo} />
|
||||||
|
<StatCard icon={<Loader2 className="h-5 w-5 text-yellow-500" />} label="In Progress" value={stats.inProgress} />
|
||||||
|
<StatCard icon={<CheckCircle2 className="h-5 w-5 text-green-500" />} label="Completed" value={stats.completed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Order By */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search tasks or projects..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Order by: {ORDER_LABELS[orderBy]}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||||
|
<DropdownMenuItem key={key} onClick={() => setOrderBy(key)}>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter Tabs */}
|
||||||
|
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||||
|
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||||
|
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* New Task Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
New Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{tasksList.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground text-sm py-12">
|
||||||
|
No tasks found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasksList.map((task) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onToggle={handleCheckboxToggle}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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