feat(tasks): Kanban board with drag-and-drop (dnd-kit)

Replaces the template's /tasks demo (deleted) with a real multi-tenant
Kanban board.

Schema/validation:
- lib/validation/tasks.ts (taskSchema with status/priority enums + dueDate
  optional + assignee/customer optional)
- lib/appwrite/task-actions.ts: createTaskAction, updateTaskAction,
  deleteTaskAction, moveTaskAction (used by drag-drop). All audit-logged;
  moveTaskAction only audits when status actually changes.
- lib/appwrite/task-queries.ts: listTasks ordered by 'order' asc.

UI:
- /tasks server page assembles { tasks, customers, teamMembers } and
  passes to TasksBoard. Removed the template's data-table demo files.
- TasksBoard (client): 4 droppable columns. Columns use @dnd-kit/core
  useDroppable; cards inside each column are SortableContext+useSortable
  for intra-column ordering. closestCorners collision detection.
- Drag-end computes new 'order' as midpoint between adjacent tasks
  (no full reindex), updates UI optimistically, then persists via
  moveTaskAction. Rolls back on server error with toast.
- TaskCard: priority badge (color-coded), due-date badge (red if
  overdue), assignee badge, customer subtitle, dropdown (Edit/Delete)
  on hover.
- TaskFormSheet: title/description/status/priority/dueDate/assignee/
  customer. Uses sentinel '__none__' for nullable Selects (Radix Select
  forbids empty string values), stripped before submit.
- DragOverlay shows the dragged card rotated 3deg with shadow.
This commit is contained in:
kovakmedya
2026-04-30 05:57:35 +03:00
parent add2317717
commit 671195fb7d
22 changed files with 1047 additions and 1793 deletions
@@ -1,256 +0,0 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { priorities, statuses, categories } from "../data/data"
import type { Task } from "../data/schema"
// Extended task schema for the form
const taskFormSchema = z.object({
id: z.string(),
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
status: z.string(),
category: z.string(),
priority: z.string(),
})
type TaskFormData = z.infer<typeof taskFormSchema>
interface AddTaskModalProps {
onAddTask?: (task: Task) => void
trigger?: React.ReactNode
}
export function AddTaskModal({ onAddTask, trigger }: AddTaskModalProps) {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState<TaskFormData>({
id: "",
title: "",
description: "",
status: "todo",
category: "feature",
priority: "normal",
})
const [errors, setErrors] = useState<Record<string, string>>({})
// Generate unique task ID
const generateTaskId = () => {
const prefix = "TASK"
const number = Math.floor(Math.random() * 9999) + 1000
return `${prefix}-${number}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
try {
// Validate form data
const validatedData = taskFormSchema.parse({
...formData,
id: generateTaskId(),
})
// Create the task
const newTask: Task = {
id: validatedData.id,
title: validatedData.title,
status: validatedData.status,
category: validatedData.category,
priority: validatedData.priority,
}
onAddTask?.(newTask)
// Reset form and close modal
setFormData({
id: "",
title: "",
description: "",
status: "todo",
category: "feature",
priority: "normal",
})
setErrors({})
setOpen(false)
} catch (error) {
if (error instanceof z.ZodError) {
const newErrors: Record<string, string> = {}
error.issues.forEach((issue) => {
if (issue.path[0]) {
newErrors[issue.path[0] as string] = issue.message
}
})
setErrors(newErrors)
}
}
}
const handleCancel = () => {
setFormData({
id: "",
title: "",
description: "",
status: "todo",
category: "feature",
priority: "normal",
})
setErrors({})
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="default" size="sm" className="cursor-pointer">
<Plus className="w-4 h-4" />
Add Task
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Add New Task</DialogTitle>
<DialogDescription>
Create a new task to track work and progress. Fill in the details below.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Task Title */}
<div className="space-y-2">
<Label htmlFor="title">Task Title *</Label>
<Input
id="title"
placeholder="Enter task title..."
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className={errors.title ? "border-red-500" : ""}
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
{/* Task Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Provide additional details about the task..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
</div>
{/* Task Status and Category - Side by Side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Task Status */}
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statuses.map((status) => (
<SelectItem key={status.value} value={status.value}>
<div className="flex items-center">
{status.icon && (
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
{status.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Task Category */}
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
value={formData.category}
onValueChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Task Priority - Half Width on Desktop */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
value={formData.priority}
onValueChange={(value) => setFormData(prev => ({ ...prev, priority: value }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority.value} value={priority.value}>
<div className="flex items-center">
{priority.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={handleCancel} className="cursor-pointer">
Cancel
</Button>
<Button type="submit" className="cursor-pointer">
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,160 +0,0 @@
"use client"
import type { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { categories, priorities, statuses } from "../data/data"
import type { Task } from "../data/schema"
import { DataTableColumnHeader } from "./data-table-column-header"
import { DataTableRowActions } from "./data-table-row-actions"
export const columns: ColumnDef<Task>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px] cursor-pointer"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px] cursor-pointer"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Task" />
),
cell: ({ row }) => (
<div className="w-[90px] font-medium">{row.getValue("id")}</div>
),
enableHiding: false,
},
{
accessorKey: "title",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Title" />
),
cell: ({ row }) => {
return (
<div className="flex space-x-2">
<span className="max-w-[500px] truncate font-medium">
{row.getValue("title")}
</span>
</div>
)
},
},
{
accessorKey: "category",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Category" />
),
cell: ({ row }) => {
const category = categories.find(
(cat) => cat.value === row.getValue("category")
)
if (!category) {
return null
}
return (
<div className="flex w-[120px] items-center">
<Badge variant="outline">
{category.label}
</Badge>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue("status")
)
if (!status) {
return null
}
return (
<div className="flex w-[130px] items-center">
{status.icon && (
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm">{status.label}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
accessorKey: "priority",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Priority" />
),
cell: ({ row }) => {
const priority = priorities.find(
(priority) => priority.value === row.getValue("priority")
)
if (!priority) {
return null
}
const priorityColors = {
critical: "border-red-700 text-red-700 dark:text-red-400",
important: "border-orange-500 text-orange-700 dark:text-orange-400",
normal: "border-blue-500 text-blue-700 dark:text-blue-400",
minor: "border-gray-500 text-gray-700 dark:text-gray-400",
}
return (
<div className="flex items-center">
<Badge
variant="outline"
className={cn(
"pl-2",
priorityColors[priority.value as keyof typeof priorityColors]
)}
>
<span className="text-sm">{priority.label}</span>
</Badge>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
id: "actions",
cell: ({ row }) => <DataTableRowActions row={row} />,
},
]
@@ -1,43 +0,0 @@
"use client"
import type { Column } from "@tanstack/react-table"
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 cursor-pointer hover:bg-accent"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</Button>
</div>
)
}
@@ -1,155 +0,0 @@
"use client"
import * as React from "react"
import type { Column } from "@tanstack/react-table"
import { PlusCircle } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed cursor-pointer">
<PlusCircle />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal cursor-pointer"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className="cursor-pointer [&_svg:not([class*='text-'])]:text-primary-foreground"
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
selectedValues.add(option.value)
} else {
selectedValues.delete(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className="mr-2"
/>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center cursor-pointer"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -1,99 +0,0 @@
"use client"
import type { Table } from "@tanstack/react-table"
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface DataTablePaginationProps<TData> {
table: Table<TData>
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground hidden lg:block">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px] cursor-pointer">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`} className="cursor-pointer">
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex cursor-pointer disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0 cursor-pointer disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft />
</Button>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<Button
variant="outline"
className="h-8 w-8 p-0 cursor-pointer disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex cursor-pointer disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight />
</Button>
</div>
</div>
</div>
)
}
@@ -1,52 +0,0 @@
"use client"
import type { Row } from "@tanstack/react-table"
import { MoreHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { taskSchema } from "../data/schema"
interface DataTableRowActionsProps<TData> {
row: Row<TData>
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const task = taskSchema.parse(row.original)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted cursor-pointer"
>
<MoreHorizontal />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem className="cursor-pointer">View Task</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Edit Task</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">Duplicate</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Mark as Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" variant="destructive">
Delete
<DropdownMenuShortcut className="text-destructive"></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,174 +0,0 @@
"use client"
import type { Table } from "@tanstack/react-table"
import { RefreshCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DataTableViewOptions } from "./data-table-view-options"
import { AddTaskModal } from "./add-task-modal"
import { categories, priorities, statuses } from "../data/data"
import type { Task } from "../data/schema"
interface DataTableToolbarProps<TData> {
table: Table<TData>
onAddTask?: (task: Task) => void
}
export function DataTableToolbar<TData>({
table,
onAddTask,
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0
const handleStatusChange = (value: string) => {
const column = table.getColumn("status")
if (value === "all") {
column?.setFilterValue(undefined)
} else {
column?.setFilterValue(value)
}
}
const handleCategoryChange = (value: string) => {
const column = table.getColumn("category")
if (value === "all") {
column?.setFilterValue(undefined)
} else {
column?.setFilterValue(value)
}
}
const handlePriorityChange = (value: string) => {
const column = table.getColumn("priority")
if (value === "all") {
column?.setFilterValue(undefined)
} else {
column?.setFilterValue(value)
}
}
const statusFilter = table.getColumn("status")?.getFilterValue() as string | undefined
const categoryFilter = table.getColumn("category")?.getFilterValue() as string | undefined
const priorityFilter = table.getColumn("priority")?.getFilterValue() as string | undefined
return (
<div className="space-y-4">
{/* Filter Section */}
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{/* Status Filter */}
<Select
value={statusFilter || "all"}
onValueChange={handleStatusChange}
>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="cursor-pointer">All Status</SelectItem>
{statuses.map((status) => (
<SelectItem
key={status.value}
value={status.value}
className="cursor-pointer"
>
<div className="flex items-center">
{status.icon && (
<status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
{status.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Category Filter */}
<Select
value={categoryFilter || "all"}
onValueChange={handleCategoryChange}
>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="cursor-pointer">All Categories</SelectItem>
{categories.map((category) => (
<SelectItem
key={category.value}
value={category.value}
className="cursor-pointer"
>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Priority Filter */}
<Select
value={priorityFilter || "all"}
onValueChange={handlePriorityChange}
>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="cursor-pointer">All Priorities</SelectItem>
{priorities.map((priority) => (
<SelectItem
key={priority.value}
value={priority.value}
className="cursor-pointer"
>
<div className="flex items-center">
{priority.icon && (
<priority.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
{priority.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Search and Actions Section */}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search Task"
value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("title")?.setFilterValue(event.target.value)
}
className=" w-[200px] lg:w-[300px] cursor-text"
/>
<Button
variant="outline"
onClick={() => table.resetColumnFilters()}
className="px-3 cursor-pointer"
disabled={!isFiltered}
>
<RefreshCcw className="h-4 w-4" />
<span className="hidden lg:block">Reset Filters</span>
</Button>
</div>
<div className="flex items-center space-x-2">
<DataTableViewOptions table={table} />
<AddTaskModal onAddTask={onAddTask} />
</div>
</div>
</div>
)
}
@@ -1,59 +0,0 @@
"use client"
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
import type { Table } from "@tanstack/react-table"
import { Settings2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
interface DataTableViewOptionsProps<TData> {
table: Table<TData>
}
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex cursor-pointer mr-2"
>
<Settings2 />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,129 +0,0 @@
"use client"
import * as React from "react"
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { DataTablePagination } from "./data-table-pagination"
import { DataTableToolbar } from "./data-table-toolbar"
import type { Task } from "../data/schema"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
onAddTask?: (task: Task) => void
}
export function DataTable<TData, TValue>({
columns,
data,
onAddTask,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="space-y-4">
<DataTableToolbar table={table} onAddTask={onAddTask} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
)
}
@@ -0,0 +1,124 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, GripVertical, MoreHorizontal, Pencil, Trash2, UserCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
import { PRIORITY_COLOR, PRIORITY_LABEL, type TaskRow } from "./types";
type Props = {
task: TaskRow;
onEdit: (task: TaskRow) => void;
onDelete: (task: TaskRow) => void;
isOverlay?: boolean;
};
export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: task.id, data: { type: "task", status: task.status } });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const overdue =
task.dueDate && task.status !== "done" && new Date(task.dueDate) < new Date();
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card group rounded-lg border p-3 shadow-sm",
isDragging && "opacity-30",
isOverlay && "rotate-3 shadow-xl",
)}
>
<div className="flex items-start gap-2">
<button
{...attributes}
{...listeners}
aria-label="Sürükle"
className="text-muted-foreground hover:text-foreground mt-0.5 cursor-grab touch-none active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="line-clamp-2 text-sm font-medium leading-snug">{task.title}</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(task)}>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => onDelete(task)}>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{task.description && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{task.description}</p>
)}
{task.customerName && (
<p className="text-muted-foreground mt-1.5 text-xs">{task.customerName}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className={cn("border-0 text-xs", PRIORITY_COLOR[task.priority])}
>
{PRIORITY_LABEL[task.priority]}
</Badge>
{task.dueDate && (
<Badge
variant="outline"
className={cn(
"gap-1 text-xs",
overdue && "text-destructive border-destructive/50",
)}
>
<Calendar className="size-3" />
{formatDate(task.dueDate)}
</Badge>
)}
{task.assigneeName && (
<Badge variant="outline" className="gap-1 text-xs">
<UserCircle className="size-3" />
{task.assigneeName}
</Badge>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createTaskAction, updateTaskAction } from "@/lib/appwrite/task-actions";
import { initialTaskState } from "@/lib/appwrite/task-types";
import type { Customer, TaskRow, TaskStatus, TeamMember } from "./types";
const NONE = "__none__";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
task?: TaskRow | null;
defaultStatus?: TaskStatus;
customers: Customer[];
teamMembers: TeamMember[];
};
function isoToInputDate(iso: string): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export function TaskFormSheet({
open,
onOpenChange,
task,
defaultStatus = "todo",
customers,
teamMembers,
}: Props) {
const isEdit = Boolean(task);
const action = isEdit ? updateTaskAction : createTaskAction;
const [state, formAction, isPending] = useActionState(action, initialTaskState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Görev güncellendi." : "Görev eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Görevi düzenle" : "Yeni görev"}</SheetTitle>
<SheetDescription>
Görev bilgilerini doldurun. Sonra Kanban'da sürükleyerek durumu değiştirebilirsiniz.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
// Strip "__none__" sentinel before submit
["assigneeId", "customerId"].forEach((k) => {
if (fd.get(k) === NONE) fd.set(k, "");
});
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && task && <input type="hidden" name="id" value={task.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="title">Başlık *</Label>
<Input
id="title"
name="title"
defaultValue={task?.title ?? ""}
placeholder="Örn. Müşteriyi ara"
required
/>
{state.fieldErrors?.title && (
<p className="text-destructive text-xs">{state.fieldErrors.title}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={4}
defaultValue={task?.description ?? ""}
placeholder="Detaylar, kabul kriterleri, vb."
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={task?.status ?? defaultStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="backlog">Beklemede</SelectItem>
<SelectItem value="todo">Yapılacak</SelectItem>
<SelectItem value="in_progress">Sürüyor</SelectItem>
<SelectItem value="done">Bitti</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="priority">Öncelik</Label>
<Select name="priority" defaultValue={task?.priority ?? "medium"}>
<SelectTrigger id="priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Düşük</SelectItem>
<SelectItem value="medium">Orta</SelectItem>
<SelectItem value="high">Yüksek</SelectItem>
<SelectItem value="urgent">Acil</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="dueDate">Son tarih</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={isoToInputDate(task?.dueDate ?? "")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="assigneeId">Atanan</Label>
<Select name="assigneeId" defaultValue={task?.assigneeId || NONE}>
<SelectTrigger id="assigneeId">
<SelectValue placeholder="Kimse" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Kimse</SelectItem>
{teamMembers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
<Select name="customerId" defaultValue={task?.customerId || NONE}>
<SelectTrigger id="customerId">
<SelectValue placeholder="Yok" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,310 @@
"use client";
import { useMemo, useState } from "react";
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useDroppable,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteTaskAction, moveTaskAction } from "@/lib/appwrite/task-actions";
import { cn } from "@/lib/utils";
import { TaskCard } from "./task-card";
import { TaskFormSheet } from "./task-form-sheet";
import {
COLUMNS,
type Customer,
type TaskRow,
type TaskStatus,
type TeamMember,
} from "./types";
import { useTransition } from "react";
type Props = {
tasks: TaskRow[];
customers: Customer[];
teamMembers: TeamMember[];
};
function Column({
status,
title,
tasks,
onAdd,
onEdit,
onDelete,
}: {
status: TaskStatus;
title: string;
tasks: TaskRow[];
onAdd: (status: TaskStatus) => void;
onEdit: (task: TaskRow) => void;
onDelete: (task: TaskRow) => void;
}) {
const { setNodeRef, isOver } = useDroppableColumn(status);
return (
<div className="bg-muted/40 flex flex-col rounded-lg border">
<div className="flex items-center justify-between border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-xs">
{tasks.length}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => onAdd(status)}
aria-label="Yeni görev"
>
<Plus className="size-3.5" />
</Button>
</div>
<div
ref={setNodeRef}
className={cn(
"flex flex-1 flex-col gap-2 p-2 transition-colors",
isOver && "bg-primary/5",
)}
>
<SortableContext
items={tasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onEdit={onEdit} onDelete={onDelete} />
))}
</SortableContext>
{tasks.length === 0 && (
<p className="text-muted-foreground py-8 text-center text-xs">Boş</p>
)}
</div>
</div>
);
}
function useDroppableColumn(status: TaskStatus) {
return useDroppable({
id: `col-${status}`,
data: { type: "column", status },
});
}
export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Props) {
const [tasks, setTasks] = useState<TaskRow[]>(initialTasks);
const [activeId, setActiveId] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [formStatus, setFormStatus] = useState<TaskStatus>("todo");
const [editing, setEditing] = useState<TaskRow | null>(null);
const [deleting, setDeleting] = useState<TaskRow | null>(null);
const [busy, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
);
const grouped = useMemo(() => {
const map: Record<TaskStatus, TaskRow[]> = {
backlog: [],
todo: [],
in_progress: [],
done: [],
};
for (const t of tasks) {
map[t.status].push(t);
}
return map;
}, [tasks]);
const activeTask = useMemo(
() => tasks.find((t) => t.id === activeId) ?? null,
[tasks, activeId],
);
const onDragStart = (e: DragStartEvent) => {
setActiveId(String(e.active.id));
};
const onDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
setActiveId(null);
if (!over || active.id === over.id) return;
const activeData = active.data.current as { type?: string; status?: TaskStatus } | undefined;
const overData = over.data.current as { type?: string; status?: TaskStatus } | undefined;
if (activeData?.type !== "task") return;
let targetStatus: TaskStatus | undefined;
if (overData?.type === "column" && overData.status) {
targetStatus = overData.status;
} else if (overData?.type === "task" && overData.status) {
targetStatus = overData.status;
}
if (!targetStatus) return;
const sourceTask = tasks.find((t) => t.id === active.id);
if (!sourceTask) return;
// Compute new order: place after the over item, or end of column
const targetTasks = tasks.filter(
(t) => t.status === targetStatus && t.id !== active.id,
);
let newOrder: number;
if (overData?.type === "task") {
const overIndex = targetTasks.findIndex((t) => t.id === over.id);
if (overIndex === -1) {
newOrder = (targetTasks[targetTasks.length - 1]?.order ?? 0) + 1000;
} else {
const before = targetTasks[overIndex - 1]?.order ?? 0;
const at = targetTasks[overIndex].order;
newOrder = (before + at) / 2;
}
} else {
newOrder = (targetTasks[targetTasks.length - 1]?.order ?? 0) + 1000;
}
// Optimistic update
setTasks((prev) =>
prev.map((t) => (t.id === sourceTask.id ? { ...t, status: targetStatus!, order: newOrder } : t)),
);
startTransition(async () => {
const result = await moveTaskAction(sourceTask.id, targetStatus!, newOrder);
if (!result.ok) {
// Rollback
setTasks((prev) =>
prev.map((t) =>
t.id === sourceTask.id ? { ...t, status: sourceTask.status, order: sourceTask.order } : t,
),
);
toast.error(result.error ?? "Görev taşınamadı.");
}
});
};
// Keep local state in sync when server data changes (e.g., after revalidate)
useMemo(() => setTasks(initialTasks), [initialTasks]);
const handleAdd = (status: TaskStatus) => {
setEditing(null);
setFormStatus(status);
setFormOpen(true);
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteTaskAction(fd);
if (result.ok) {
toast.success("Görev silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<>
<div className="flex justify-end">
<Button onClick={() => handleAdd("todo")}>
<Plus className="size-4" />
Yeni görev
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => (
<Column
key={col.key}
status={col.key}
title={col.title}
tasks={grouped[col.key]}
onAdd={handleAdd}
onEdit={(t) => {
setEditing(t);
setFormOpen(true);
}}
onDelete={(t) => setDeleting(t)}
/>
))}
</div>
<DragOverlay>
{activeTask && (
<TaskCard
task={activeTask}
onEdit={() => {}}
onDelete={() => {}}
isOverlay
/>
)}
</DragOverlay>
</DndContext>
<TaskFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
task={editing}
defaultStatus={formStatus}
customers={customers}
teamMembers={teamMembers}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Görevi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,40 @@
export type TaskStatus = "backlog" | "todo" | "in_progress" | "done";
export type TaskPriority = "low" | "medium" | "high" | "urgent";
export type TaskRow = {
id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
dueDate: string;
assigneeId: string;
assigneeName: string;
customerId: string;
customerName: string;
order: number;
};
export type Customer = { id: string; name: string };
export type TeamMember = { id: string; name: string };
export const COLUMNS: { key: TaskStatus; title: string }[] = [
{ key: "backlog", title: "Beklemede" },
{ key: "todo", title: "Yapılacak" },
{ key: "in_progress", title: "Sürüyor" },
{ key: "done", title: "Bitti" },
];
export const PRIORITY_LABEL: Record<TaskPriority, string> = {
low: "Düşük",
medium: "Orta",
high: "Yüksek",
urgent: "Acil",
};
export const PRIORITY_COLOR: Record<TaskPriority, string> = {
low: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
medium: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
high: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
urgent: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300",
};
@@ -1,64 +0,0 @@
"use client"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function UserNav() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer">
<Avatar className="h-9 w-9 cursor-pointer">
<AvatarImage src="https://notion-avatars.netlify.app/api/avatar/?preset=female-2" alt="@shadcn" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">shadcn</p>
<p className="text-xs leading-none text-muted-foreground">
m@example.com
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="cursor-pointer">
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
-71
View File
@@ -1,71 +0,0 @@
import {
CheckCircle2,
Circle,
Clock,
PlayCircle,
} from "lucide-react"
export const categories = [
{
value: "bug",
label: "Bug",
},
{
value: "feature",
label: "Feature",
},
{
value: "documentation",
label: "Docs",
},
{
value: "improvement",
label: "Improvement",
},
{
value: "refactor",
label: "Refactor",
},
]
export const statuses = [
{
value: "pending",
label: "Pending",
icon: Clock,
},
{
value: "todo",
label: "Todo",
icon: Circle,
},
{
value: "in progress",
label: "In Progress",
icon: PlayCircle,
},
{
value: "completed",
label: "Completed",
icon: CheckCircle2,
},
]
export const priorities = [
{
label: "Minor",
value: "minor"
},
{
label: "Normal",
value: "normal"
},
{
label: "Important",
value: "important"
},
{
label: "Critical",
value: "critical"
},
]
-13
View File
@@ -1,13 +0,0 @@
import { z } from "zod"
// We're keeping a simple non-relational schema here.
// IRL, you will have a schema for your data models.
export const taskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.string(),
category: z.string(),
priority: z.string(),
})
export type Task = z.infer<typeof taskSchema>
-352
View File
@@ -1,352 +0,0 @@
[
{
"id": "TASK-1001",
"title": "Implement user authentication with OAuth 2.0 providers",
"status": "completed",
"category": "feature",
"priority": "critical"
},
{
"id": "TASK-1002",
"title": "Fix memory leak in dashboard component causing browser slowdown",
"status": "in progress",
"category": "bug",
"priority": "critical"
},
{
"id": "TASK-1003",
"title": "Update API documentation for v2 endpoints",
"status": "todo",
"category": "documentation",
"priority": "important"
},
{
"id": "TASK-1004",
"title": "Add dark mode support for all UI components",
"status": "completed",
"category": "feature",
"priority": "important"
},
{
"id": "TASK-1005",
"title": "Resolve login page not redirecting after successful authentication",
"status": "in progress",
"category": "bug",
"priority": "critical"
},
{
"id": "TASK-1006",
"title": "Create onboarding tutorial for new users",
"status": "pending",
"category": "documentation",
"priority": "normal"
},
{
"id": "TASK-1007",
"title": "Optimize database queries for user dashboard",
"status": "todo",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1008",
"title": "Refactor notification service for better maintainability",
"status": "pending",
"category": "refactor",
"priority": "normal"
},
{
"id": "TASK-1009",
"title": "Fix broken image upload on mobile devices",
"status": "in progress",
"category": "bug",
"priority": "important"
},
{
"id": "TASK-1010",
"title": "Implement real-time notifications using WebSockets",
"status": "todo",
"category": "feature",
"priority": "important"
},
{
"id": "TASK-1011",
"title": "Write unit tests for payment processing module",
"status": "pending",
"category": "improvement",
"priority": "critical"
},
{
"id": "TASK-1012",
"title": "Update README with installation instructions",
"status": "completed",
"category": "documentation",
"priority": "minor"
},
{
"id": "TASK-1013",
"title": "Design and implement user profile settings page",
"status": "in progress",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1014",
"title": "Fix timezone conversion issues in calendar view",
"status": "todo",
"category": "bug",
"priority": "important"
},
{
"id": "TASK-1015",
"title": "Refactor legacy authentication code to use new security standards",
"status": "pending",
"category": "refactor",
"priority": "critical"
},
{
"id": "TASK-1016",
"title": "Add export functionality for analytics reports",
"status": "completed",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1017",
"title": "Create API rate limiting documentation",
"status": "todo",
"category": "documentation",
"priority": "normal"
},
{
"id": "TASK-1018",
"title": "Resolve CSS styling conflicts in sidebar navigation",
"status": "completed",
"category": "bug",
"priority": "minor"
},
{
"id": "TASK-1019",
"title": "Implement lazy loading for dashboard widgets",
"status": "in progress",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1020",
"title": "Add multi-language support for user interface",
"status": "pending",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1021",
"title": "Fix form validation errors not displaying correctly",
"status": "todo",
"category": "bug",
"priority": "important"
},
{
"id": "TASK-1022",
"title": "Write migration guide from v1 to v2",
"status": "pending",
"category": "documentation",
"priority": "important"
},
{
"id": "TASK-1023",
"title": "Refactor data fetching hooks for better reusability",
"status": "todo",
"category": "refactor",
"priority": "normal"
},
{
"id": "TASK-1024",
"title": "Implement file drag and drop upload feature",
"status": "completed",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1025",
"title": "Fix search functionality returning incorrect results",
"status": "in progress",
"category": "bug",
"priority": "critical"
},
{
"id": "TASK-1026",
"title": "Improve error handling across all API endpoints",
"status": "todo",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1027",
"title": "Add keyboard shortcuts documentation",
"status": "completed",
"category": "documentation",
"priority": "minor"
},
{
"id": "TASK-1028",
"title": "Implement two-factor authentication option",
"status": "pending",
"category": "feature",
"priority": "critical"
},
{
"id": "TASK-1029",
"title": "Resolve session timeout not working properly",
"status": "todo",
"category": "bug",
"priority": "important"
},
{
"id": "TASK-1030",
"title": "Refactor component library for better tree shaking",
"status": "pending",
"category": "refactor",
"priority": "normal"
},
{
"id": "TASK-1031",
"title": "Add accessibility improvements for screen readers",
"status": "in progress",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1032",
"title": "Create troubleshooting guide for common issues",
"status": "todo",
"category": "documentation",
"priority": "normal"
},
{
"id": "TASK-1033",
"title": "Implement bulk action operations for data tables",
"status": "completed",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1034",
"title": "Fix notification badge count not updating in real-time",
"status": "in progress",
"category": "bug",
"priority": "normal"
},
{
"id": "TASK-1035",
"title": "Optimize image compression for faster page loads",
"status": "pending",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1036",
"title": "Add SSO integration for enterprise customers",
"status": "todo",
"category": "feature",
"priority": "critical"
},
{
"id": "TASK-1037",
"title": "Document component props and usage examples",
"status": "in progress",
"category": "documentation",
"priority": "important"
},
{
"id": "TASK-1038",
"title": "Fix data export generating corrupted files",
"status": "completed",
"category": "bug",
"priority": "critical"
},
{
"id": "TASK-1039",
"title": "Refactor state management to use Redux Toolkit",
"status": "pending",
"category": "refactor",
"priority": "important"
},
{
"id": "TASK-1040",
"title": "Implement custom dashboard layout builder",
"status": "todo",
"category": "feature",
"priority": "normal"
},
{
"id": "TASK-1041",
"title": "Add performance monitoring and alerting",
"status": "pending",
"category": "improvement",
"priority": "important"
},
{
"id": "TASK-1042",
"title": "Fix dropdown menu not closing on outside click",
"status": "completed",
"category": "bug",
"priority": "minor"
},
{
"id": "TASK-1043",
"title": "Create video tutorials for key features",
"status": "pending",
"category": "documentation",
"priority": "normal"
},
{
"id": "TASK-1044",
"title": "Implement advanced search filters",
"status": "in progress",
"category": "feature",
"priority": "important"
},
{
"id": "TASK-1045",
"title": "Refactor API client for better error recovery",
"status": "todo",
"category": "refactor",
"priority": "normal"
},
{
"id": "TASK-1046",
"title": "Fix chart rendering issues on Safari browser",
"status": "todo",
"category": "bug",
"priority": "important"
},
{
"id": "TASK-1047",
"title": "Add automated backup functionality",
"status": "pending",
"category": "feature",
"priority": "critical"
},
{
"id": "TASK-1048",
"title": "Improve loading states across the application",
"status": "completed",
"category": "improvement",
"priority": "normal"
},
{
"id": "TASK-1049",
"title": "Write integration tests for checkout flow",
"status": "in progress",
"category": "improvement",
"priority": "critical"
},
{
"id": "TASK-1050",
"title": "Fix email notifications being marked as spam",
"status": "todo",
"category": "bug",
"priority": "important"
}
]
+55 -163
View File
@@ -1,179 +1,71 @@
"use client" import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react" import { listCustomers } from "@/lib/appwrite/customer-queries";
import { z } from "zod" import { listTasks } from "@/lib/appwrite/task-queries";
import { ArrowUp, BarChart3, CheckCircle2, Clock, ListTodo } from "lucide-react" import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { createAdminClient } from "@/lib/appwrite/server";
import { TasksBoard } from "./components/tasks-board";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" export const metadata: Metadata = {
import { columns } from "./components/columns" title: "İşletmem — Görevler",
import { DataTable } from "./components/data-table" };
import { taskSchema, type Task } from "./data/schema"
import tasksData from "./data/tasks.json"
// Use static import for tasks data (works in both Vite and Next.js) export default async function TasksPage() {
async function getTasks() { let ctx;
return z.array(taskSchema).parse(tasksData)
}
export default function TaskPage() {
const [tasks, setTasks] = useState<z.infer<typeof taskSchema>[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadTasks = async () => {
try { try {
const taskList = await getTasks() ctx = await requireTenant();
setTasks(taskList) } catch {
} catch (error) { redirect("/onboarding");
console.error("Failed to load tasks:", error)
} finally {
setLoading(false)
}
} }
loadTasks() const [tasks, customers] = await Promise.all([
}, []) listTasks(ctx.tenantId),
listCustomers(ctx.tenantId),
]);
const handleAddTask = (newTask: Task) => { let teamMembers: { id: string; name: string }[] = [];
setTasks(prev => [newTask, ...prev]) try {
const { teams } = createAdminClient();
const memberships = await teams.listMemberships(ctx.tenantId);
teamMembers = memberships.memberships.map((m) => ({
id: m.userId,
name: m.userName || m.userEmail,
}));
} catch {
/* ignore */
} }
// Calculate statistics const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
const stats = { const memberMap = new Map(teamMembers.map((m) => [m.id, m.name]));
total: tasks.length,
completed: tasks.filter(t => t.status === "completed").length,
inProgress: tasks.filter(t => t.status === "in progress").length,
pending: tasks.filter(t => t.status === "pending").length,
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Loading tasks...</div>
</div>
)
}
return ( return (
<> <div className="flex-1 space-y-6 px-6 pt-0">
{/* Page Header */} <div className="flex flex-col gap-1">
<div className="flex flex-col gap-2 px-4 md:px-6"> <p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Tasks</h1> <h1 className="text-2xl font-bold tracking-tight">Görevler</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-sm">
A powerful task and issue tracker built with Tanstack Table. Sürükle-bırak ile durumları değiştirin, ekibinizle takibini kolaylaştırın.
</p> </p>
</div> </div>
{/* Mobile view placeholder - shows message instead of images */} <TasksBoard
<div className="md:hidden px-4 md:px-6"> tasks={tasks.map((t) => ({
<div className="flex items-center justify-center h-96 border rounded-lg bg-muted/20"> id: t.$id,
<div className="text-center p-8"> title: t.title,
<h3 className="text-lg font-semibold mb-2">Tasks Dashboard</h3> description: t.description ?? "",
<p className="text-muted-foreground"> status: t.status ?? "todo",
Please use a larger screen to view the full tasks interface. priority: t.priority ?? "medium",
</p> dueDate: t.dueDate ?? "",
assigneeId: t.assigneeId ?? "",
assigneeName: t.assigneeId ? memberMap.get(t.assigneeId) ?? "" : "",
customerId: t.customerId ?? "",
customerName: t.customerId ? customerMap.get(t.customerId) ?? "" : "",
order: t.order ?? 0,
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
teamMembers={teamMembers}
/>
</div> </div>
</div> );
</div>
{/* Desktop view */}
<div className="hidden h-full flex-1 flex-col space-y-6 px-4 md:px-6 md:flex">
{/* Stats Cards */}
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Tasks</p>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-bold">{stats.total}</span>
<span className="flex items-center gap-0.5 text-sm text-green-500">
<ArrowUp className="size-3.5" />
{stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0}%
</span>
</div>
</div>
<div className="bg-secondary rounded-lg p-3">
<ListTodo className="size-6" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Completed</p>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-bold">{stats.completed}</span>
<span className="flex items-center gap-0.5 text-sm text-green-500">
<ArrowUp className="size-3.5" />
{Math.round((stats.completed / stats.total) * 100)}%
</span>
</div>
</div>
<div className="bg-secondary rounded-lg p-3">
<CheckCircle2 className="size-6" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">In Progress</p>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-bold">{stats.inProgress}</span>
<span className="flex items-center gap-0.5 text-sm text-green-500">
<ArrowUp className="size-3.5" />
{Math.round((stats.inProgress / stats.total) * 100)}%
</span>
</div>
</div>
<div className="bg-secondary rounded-lg p-3">
<Clock className="size-6" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Pending</p>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-bold">{stats.pending}</span>
<span className="flex items-center gap-0.5 text-sm text-orange-500">
<ArrowUp className="size-3.5" />
{Math.round((stats.pending / stats.total) * 100)}%
</span>
</div>
</div>
<div className="bg-secondary rounded-lg p-3">
<BarChart3 className="size-6" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Task Management</CardTitle>
<CardDescription>
View, filter, and manage all your project tasks in one place
</CardDescription>
</CardHeader>
<CardContent>
<DataTable data={tasks} columns={columns} onAddTask={handleAddTask} />
</CardContent>
</Card>
</div>
</>
)
} }
+244
View File
@@ -0,0 +1,244 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type Task, type TaskStatus } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { TaskActionState } from "./task-types";
import { taskSchema } from "@/lib/validation/tasks";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
return e.message || "Beklenmeyen bir hata oluştu.";
}
return "Bağlantı hatası. Tekrar deneyin.";
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
function pickFormFields(formData: FormData) {
return {
title: String(formData.get("title") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
status: (formData.get("status") as TaskStatus | null) ?? "todo",
priority: (formData.get("priority") as "low" | "medium" | "high" | "urgent" | null) ??
"medium",
dueDate: String(formData.get("dueDate") ?? ""),
assigneeId: String(formData.get("assigneeId") ?? ""),
customerId: String(formData.get("customerId") ?? ""),
};
}
function toIsoDate(v?: string): string | undefined {
if (!v) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
export async function createTaskAction(
_prev: TaskActionState,
formData: FormData,
): Promise<TaskActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = taskSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.tasks,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
order: Date.now(),
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "task",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
export async function updateTaskAction(
_prev: TaskActionState,
formData: FormData,
): Promise<TaskActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = taskSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "task",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
export async function deleteTaskAction(formData: FormData): Promise<TaskActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.tasks, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "task",
entityId: id,
changes: { title: existing.title },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
/**
* Used by Kanban drag-drop. Updates status and order in one server call.
*/
export async function moveTaskAction(
id: string,
status: TaskStatus,
order: number,
): Promise<TaskActionState> {
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, { status, order });
if (existing.status !== status) {
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "task",
entityId: id,
changes: { from: existing.status, to: status },
});
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Task } from "./schema";
export async function listTasks(tenantId: string): Promise<Task[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tasks,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("order"),
Query.limit(1000),
],
});
return result.rows as unknown as Task[];
} catch {
return [];
}
}
+7
View File
@@ -0,0 +1,7 @@
export type TaskActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialTaskState: TaskActionState = { ok: false };
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
export const taskSchema = z.object({
title: z.string().trim().min(1, "Başlık zorunlu.").max(255),
description: z
.string()
.trim()
.max(5000)
.optional()
.transform((v) => (v ? v : undefined)),
status: z.enum(["backlog", "todo", "in_progress", "done"]).optional().default("todo"),
priority: z.enum(["low", "medium", "high", "urgent"]).optional().default("medium"),
dueDate: z.string().optional().transform((v) => (v ? v : undefined)),
assigneeId: z.string().optional().transform((v) => (v ? v : undefined)),
customerId: z.string().optional().transform((v) => (v ? v : undefined)),
});
export type TaskInput = z.infer<typeof taskSchema>;