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 { z } from "zod"
|
||||
import { ArrowUp, BarChart3, CheckCircle2, Clock, ListTodo } from "lucide-react"
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { listTasks } from "@/lib/appwrite/task-queries";
|
||||
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"
|
||||
import { columns } from "./components/columns"
|
||||
import { DataTable } from "./components/data-table"
|
||||
import { taskSchema, type Task } from "./data/schema"
|
||||
import tasksData from "./data/tasks.json"
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Görevler",
|
||||
};
|
||||
|
||||
// Use static import for tasks data (works in both Vite and Next.js)
|
||||
async function getTasks() {
|
||||
return z.array(taskSchema).parse(tasksData)
|
||||
}
|
||||
|
||||
export default function TaskPage() {
|
||||
const [tasks, setTasks] = useState<z.infer<typeof taskSchema>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadTasks = async () => {
|
||||
export default async function TasksPage() {
|
||||
let ctx;
|
||||
try {
|
||||
const taskList = await getTasks()
|
||||
setTasks(taskList)
|
||||
} catch (error) {
|
||||
console.error("Failed to load tasks:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
}, [])
|
||||
const [tasks, customers] = await Promise.all([
|
||||
listTasks(ctx.tenantId),
|
||||
listCustomers(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const handleAddTask = (newTask: Task) => {
|
||||
setTasks(prev => [newTask, ...prev])
|
||||
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 */
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter(t => t.status === "completed").length,
|
||||
inProgress: tasks.filter(t => t.status === "in progress").length,
|
||||
pending: tasks.filter(t => t.status === "pending").length,
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">Loading tasks...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
|
||||
const memberMap = new Map(teamMembers.map((m) => [m.id, m.name]));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-2 px-4 md:px-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Tasks</h1>
|
||||
<p className="text-muted-foreground">
|
||||
A powerful task and issue tracker built with Tanstack Table.
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Görevler</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sürükle-bırak ile durumları değiştirin, ekibinizle iş takibini kolaylaştırın.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile view placeholder - shows message instead of images */}
|
||||
<div className="md:hidden px-4 md:px-6">
|
||||
<div className="flex items-center justify-center h-96 border rounded-lg bg-muted/20">
|
||||
<div className="text-center p-8">
|
||||
<h3 className="text-lg font-semibold mb-2">Tasks Dashboard</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Please use a larger screen to view the full tasks interface.
|
||||
</p>
|
||||
<TasksBoard
|
||||
tasks={tasks.map((t) => ({
|
||||
id: t.$id,
|
||||
title: t.title,
|
||||
description: t.description ?? "",
|
||||
status: t.status ?? "todo",
|
||||
priority: t.priority ?? "medium",
|
||||
dueDate: t.dueDate ?? "",
|
||||
assigneeId: t.assigneeId ?? "",
|
||||
assigneeName: t.assigneeId ? memberMap.get(t.assigneeId) ?? "" : "",
|
||||
customerId: t.customerId ?? "",
|
||||
customerName: t.customerId ? customerMap.get(t.customerId) ?? "" : "",
|
||||
order: t.order ?? 0,
|
||||
}))}
|
||||
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop view */}
|
||||
<div className="hidden h-full flex-1 flex-col space-y-6 px-4 md:px-6 md:flex">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Tasks</p>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">{stats.total}</span>
|
||||
<span className="flex items-center gap-0.5 text-sm text-green-500">
|
||||
<ArrowUp className="size-3.5" />
|
||||
{stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary rounded-lg p-3">
|
||||
<ListTodo className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Completed</p>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">{stats.completed}</span>
|
||||
<span className="flex items-center gap-0.5 text-sm text-green-500">
|
||||
<ArrowUp className="size-3.5" />
|
||||
{Math.round((stats.completed / stats.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary rounded-lg p-3">
|
||||
<CheckCircle2 className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">In Progress</p>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">{stats.inProgress}</span>
|
||||
<span className="flex items-center gap-0.5 text-sm text-green-500">
|
||||
<ArrowUp className="size-3.5" />
|
||||
{Math.round((stats.inProgress / stats.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary rounded-lg p-3">
|
||||
<Clock className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Pending</p>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">{stats.pending}</span>
|
||||
<span className="flex items-center gap-0.5 text-sm text-orange-500">
|
||||
<ArrowUp className="size-3.5" />
|
||||
{Math.round((stats.pending / stats.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary rounded-lg p-3">
|
||||
<BarChart3 className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Task Management</CardTitle>
|
||||
<CardDescription>
|
||||
View, filter, and manage all your project tasks in one place
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable data={tasks} columns={columns} onAddTask={handleAddTask} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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