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:
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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)
|
try {
|
||||||
}
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
export default function TaskPage() {
|
redirect("/onboarding");
|
||||||
const [tasks, setTasks] = useState<z.infer<typeof taskSchema>[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTasks = async () => {
|
|
||||||
try {
|
|
||||||
const taskList = await getTasks()
|
|
||||||
setTasks(taskList)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load tasks:", error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTasks()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleAddTask = (newTask: Task) => {
|
|
||||||
setTasks(prev => [newTask, ...prev])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate statistics
|
const [tasks, customers] = await Promise.all([
|
||||||
const stats = {
|
listTasks(ctx.tenantId),
|
||||||
total: tasks.length,
|
listCustomers(ctx.tenantId),
|
||||||
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,
|
let teamMembers: { id: string; name: string }[] = [];
|
||||||
|
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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
|
||||||
return (
|
const memberMap = new Map(teamMembers.map((m) => [m.id, m.name]));
|
||||||
<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 iş 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 ?? "",
|
||||||
</div>
|
assigneeId: t.assigneeId ?? "",
|
||||||
</div>
|
assigneeName: t.assigneeId ? memberMap.get(t.assigneeId) ?? "" : "",
|
||||||
</div>
|
customerId: t.customerId ?? "",
|
||||||
|
customerName: t.customerId ? customerMap.get(t.customerId) ?? "" : "",
|
||||||
{/* Desktop view */}
|
order: t.order ?? 0,
|
||||||
<div className="hidden h-full flex-1 flex-col space-y-6 px-4 md:px-6 md:flex">
|
}))}
|
||||||
{/* Stats Cards */}
|
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
|
teamMembers={teamMembers}
|
||||||
<Card>
|
/>
|
||||||
<CardContent>
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type TaskActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialTaskState: TaskActionState = { ok: false };
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user