feat(tasks): add task status update functionality and improve relation ID handling
This commit is contained in:
@@ -11,6 +11,11 @@ function cleanText(value: FormDataEntryValue | null) {
|
|||||||
return text.length > 0 ? text : null;
|
return text.length > 0 ? text : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanRelationId(value: FormDataEntryValue | null) {
|
||||||
|
const id = cleanText(value);
|
||||||
|
return id && id !== "__none" ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
function readStatus(value: FormDataEntryValue | null) {
|
function readStatus(value: FormDataEntryValue | null) {
|
||||||
const status = typeof value === "string" ? value : "todo";
|
const status = typeof value === "string" ? value : "todo";
|
||||||
return TASK_STATUSES.includes(status as (typeof TASK_STATUSES)[number])
|
return TASK_STATUSES.includes(status as (typeof TASK_STATUSES)[number])
|
||||||
@@ -50,8 +55,8 @@ function readPayload(formData: FormData) {
|
|||||||
description: cleanText(formData.get("description")),
|
description: cleanText(formData.get("description")),
|
||||||
status: readStatus(formData.get("status")),
|
status: readStatus(formData.get("status")),
|
||||||
priority: readPriority(formData.get("priority")),
|
priority: readPriority(formData.get("priority")),
|
||||||
client_id: cleanText(formData.get("client_id")),
|
client_id: cleanRelationId(formData.get("client_id")),
|
||||||
project_id: cleanText(formData.get("project_id")),
|
project_id: cleanRelationId(formData.get("project_id")),
|
||||||
due_at: cleanText(formData.get("due_at")),
|
due_at: cleanText(formData.get("due_at")),
|
||||||
estimated_minutes: readMinutes(formData.get("estimated_minutes")),
|
estimated_minutes: readMinutes(formData.get("estimated_minutes")),
|
||||||
actual_minutes: readMinutes(formData.get("actual_minutes")),
|
actual_minutes: readMinutes(formData.get("actual_minutes")),
|
||||||
@@ -122,6 +127,27 @@ export async function completeTaskRecord(formData: FormData) {
|
|||||||
revalidatePath("/tasks");
|
revalidatePath("/tasks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatusRecord(taskId: string, status: string) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const nextStatus = readStatus(status);
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
throw new Error("Durumu güncellenecek görev bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("tasks")
|
||||||
|
.update({ status: nextStatus })
|
||||||
|
.eq("id", taskId)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Görev durumu güncellenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/tasks");
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteTaskRecord(formData: FormData) {
|
export async function deleteTaskRecord(formData: FormData) {
|
||||||
const { supabase, userId } = await getCurrentUserId();
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
const id = cleanText(formData.get("id"));
|
const id = cleanText(formData.get("id"));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
completeTaskRecord,
|
completeTaskRecord,
|
||||||
createTaskRecord,
|
createTaskRecord,
|
||||||
deleteTaskRecord,
|
deleteTaskRecord,
|
||||||
|
updateTaskStatusRecord,
|
||||||
updateTaskRecord,
|
updateTaskRecord,
|
||||||
} from "@/app/(dashboard)/tasks/actions";
|
} from "@/app/(dashboard)/tasks/actions";
|
||||||
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState, useTransition, type DragEvent } from "react";
|
||||||
|
|
||||||
export type TaskRelationOption = {
|
export type TaskRelationOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,20 +82,48 @@ type TasksClientProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
||||||
|
const [localTasks, setLocalTasks] = useState(tasks);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [projectFilter, setProjectFilter] = useState("__all");
|
||||||
const [view, setView] = useState<"list" | "kanban">("list");
|
const [view, setView] = useState<"list" | "kanban">("list");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalTasks(tasks);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
function handleTaskStatusChange(taskId: string, status: TaskListItem["status"]) {
|
||||||
|
const previousTasks = localTasks;
|
||||||
|
|
||||||
|
setLocalTasks((currentTasks) =>
|
||||||
|
currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)),
|
||||||
|
);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
void updateTaskStatusRecord(taskId, status).catch(() => {
|
||||||
|
setLocalTasks(previousTasks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredByProject =
|
||||||
|
projectFilter === "__all"
|
||||||
|
? localTasks
|
||||||
|
: projectFilter === "__none"
|
||||||
|
? localTasks.filter((task) => !task.project_id)
|
||||||
|
: localTasks.filter((task) => task.project_id === projectFilter);
|
||||||
const filteredTasks = normalizedQuery
|
const filteredTasks = normalizedQuery
|
||||||
? tasks.filter((task) =>
|
? filteredByProject.filter((task) =>
|
||||||
[task.title, task.description, task.clientName, task.projectName]
|
[task.title, task.description, task.clientName, task.projectName]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
||||||
)
|
)
|
||||||
: tasks;
|
: filteredByProject;
|
||||||
|
|
||||||
const doneCount = tasks.filter((task) => task.status === "done").length;
|
const doneCount = localTasks.filter((task) => task.status === "done").length;
|
||||||
const overdueCount = tasks.filter((task) => isOverdue(task)).length;
|
const overdueCount = localTasks.filter((task) => isOverdue(task)).length;
|
||||||
const urgentCount = tasks.filter((task) => task.priority === "urgent").length;
|
const urgentCount = localTasks.filter((task) => task.priority === "urgent").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
@@ -118,7 +147,7 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<StatCard label="Toplam görev" value={tasks.length.toString()} />
|
<StatCard label="Toplam görev" value={localTasks.length.toString()} />
|
||||||
<StatCard label="Tamamlanan" value={doneCount.toString()} />
|
<StatCard label="Tamamlanan" value={doneCount.toString()} />
|
||||||
<StatCard label="Geciken" value={overdueCount.toString()} />
|
<StatCard label="Geciken" value={overdueCount.toString()} />
|
||||||
<StatCard label="Acil" value={urgentCount.toString()} />
|
<StatCard label="Acil" value={urgentCount.toString()} />
|
||||||
@@ -140,6 +169,20 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
|||||||
placeholder="Görev, proje veya müşteri ara"
|
placeholder="Görev, proje veya müşteri ara"
|
||||||
className="sm:w-80"
|
className="sm:w-80"
|
||||||
/>
|
/>
|
||||||
|
<Select value={projectFilter} onValueChange={setProjectFilter}>
|
||||||
|
<SelectTrigger className="sm:w-56">
|
||||||
|
<SelectValue placeholder="Proje filtrele" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all">Tüm projeler</SelectItem>
|
||||||
|
<SelectItem value="__none">Projesiz görevler</SelectItem>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<SelectItem key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<div className="flex rounded-sm border border-border p-1">
|
<div className="flex rounded-sm border border-border p-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -167,7 +210,12 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
|||||||
view === "list" ? (
|
view === "list" ? (
|
||||||
<TaskList tasks={filteredTasks} clients={clients} projects={projects} />
|
<TaskList tasks={filteredTasks} clients={clients} projects={projects} />
|
||||||
) : (
|
) : (
|
||||||
<TaskKanban tasks={filteredTasks} clients={clients} projects={projects} />
|
<TaskKanban
|
||||||
|
tasks={filteredTasks}
|
||||||
|
clients={clients}
|
||||||
|
projects={projects}
|
||||||
|
onTaskStatusChange={handleTaskStatusChange}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<EmptyState hasQuery={Boolean(normalizedQuery)} />
|
<EmptyState hasQuery={Boolean(normalizedQuery)} />
|
||||||
@@ -245,12 +293,29 @@ function TaskKanban({
|
|||||||
tasks,
|
tasks,
|
||||||
clients,
|
clients,
|
||||||
projects,
|
projects,
|
||||||
|
onTaskStatusChange,
|
||||||
}: {
|
}: {
|
||||||
tasks: TaskListItem[];
|
tasks: TaskListItem[];
|
||||||
clients: TaskRelationOption[];
|
clients: TaskRelationOption[];
|
||||||
projects: TaskRelationOption[];
|
projects: TaskRelationOption[];
|
||||||
|
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
|
||||||
}) {
|
}) {
|
||||||
const columns = ["todo", "in_progress", "done"] as const;
|
const columns = ["todo", "in_progress", "done"] as const;
|
||||||
|
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleDragStart(event: DragEvent<HTMLDivElement>, taskId: string) {
|
||||||
|
setDraggedTaskId(taskId);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", taskId);
|
||||||
|
event.dataTransfer.setDragImage(event.currentTarget, 24, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(status: TaskListItem["status"]) {
|
||||||
|
if (!draggedTaskId) return;
|
||||||
|
|
||||||
|
onTaskStatusChange(draggedTaskId, status);
|
||||||
|
setDraggedTaskId(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tiny-scrollbar grid gap-4 overflow-x-auto pb-2 lg:grid-cols-3">
|
<div className="tiny-scrollbar grid gap-4 overflow-x-auto pb-2 lg:grid-cols-3">
|
||||||
@@ -258,14 +323,32 @@ function TaskKanban({
|
|||||||
const columnTasks = tasks.filter((task) => task.status === status);
|
const columnTasks = tasks.filter((task) => task.status === status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={status} className="min-w-72 rounded-sm border border-border bg-muted/20 p-3">
|
<div
|
||||||
|
key={status}
|
||||||
|
className="min-w-72 rounded-sm border border-border bg-muted/20 p-3 transition-colors"
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}}
|
||||||
|
onDrop={() => handleDrop(status)}
|
||||||
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-foreground">{statusLabels[status]}</h3>
|
<h3 className="text-sm font-semibold text-foreground">{statusLabels[status]}</h3>
|
||||||
<Badge>{columnTasks.length}</Badge>
|
<Badge>{columnTasks.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{columnTasks.map((task) => (
|
{columnTasks.map((task) => (
|
||||||
<Card key={task.id}>
|
<Card
|
||||||
|
key={task.id}
|
||||||
|
draggable
|
||||||
|
className={
|
||||||
|
draggedTaskId === task.id
|
||||||
|
? "cursor-grabbing opacity-50 ring-2 ring-primary/20 transition"
|
||||||
|
: "cursor-grab transition hover:border-primary/30 active:cursor-grabbing"
|
||||||
|
}
|
||||||
|
onDragStart={(event) => handleDragStart(event, task.id)}
|
||||||
|
onDragEnd={() => setDraggedTaskId(null)}
|
||||||
|
>
|
||||||
<CardContent className="space-y-3 p-3">
|
<CardContent className="space-y-3 p-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">{task.title}</div>
|
<div className="font-medium text-foreground">{task.title}</div>
|
||||||
@@ -399,6 +482,39 @@ function TaskFormFields({
|
|||||||
clients: TaskRelationOption[];
|
clients: TaskRelationOption[];
|
||||||
projects: TaskRelationOption[];
|
projects: TaskRelationOption[];
|
||||||
}) {
|
}) {
|
||||||
|
const [clientId, setClientId] = useState(task?.client_id || "__none");
|
||||||
|
const [projectId, setProjectId] = useState(task?.project_id || "__none");
|
||||||
|
const selectedProject =
|
||||||
|
projectId === "__none" ? null : projects.find((project) => project.id === projectId) || null;
|
||||||
|
const shouldLockClient = Boolean(selectedProject);
|
||||||
|
const filteredProjects =
|
||||||
|
clientId === "__none" || shouldLockClient
|
||||||
|
? projects
|
||||||
|
: projects.filter((project) => project.client_id === clientId);
|
||||||
|
|
||||||
|
function handleClientChange(nextClientId: string) {
|
||||||
|
setClientId(nextClientId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
projectId !== "__none" &&
|
||||||
|
nextClientId !== "__none" &&
|
||||||
|
!projects.some((project) => project.id === projectId && project.client_id === nextClientId)
|
||||||
|
) {
|
||||||
|
setProjectId("__none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectChange(nextProjectId: string) {
|
||||||
|
setProjectId(nextProjectId);
|
||||||
|
|
||||||
|
if (nextProjectId === "__none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProject = projects.find((project) => project.id === nextProjectId);
|
||||||
|
setClientId(nextProject?.client_id || "__none");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -438,20 +554,44 @@ function TaskFormFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<SelectField name="client_id" label="Müşteri" defaultValue={task?.client_id || ""}>
|
<div className="grid gap-2">
|
||||||
{clients.map((client) => (
|
<Label>Müşteri</Label>
|
||||||
<SelectItem key={client.id} value={client.id}>
|
{shouldLockClient ? <input type="hidden" name="client_id" value={clientId} /> : null}
|
||||||
{client.name}
|
<Select
|
||||||
</SelectItem>
|
name="client_id"
|
||||||
))}
|
value={clientId}
|
||||||
</SelectField>
|
onValueChange={handleClientChange}
|
||||||
<SelectField name="project_id" label="Proje" defaultValue={task?.project_id || ""}>
|
disabled={shouldLockClient}
|
||||||
{projects.map((project) => (
|
>
|
||||||
<SelectItem key={project.id} value={project.id}>
|
<SelectTrigger>
|
||||||
{project.name}
|
<SelectValue placeholder="Müşteri seç" />
|
||||||
</SelectItem>
|
</SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
</SelectField>
|
<SelectItem value="__none">Müşteri yok</SelectItem>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Proje</Label>
|
||||||
|
<Select name="project_id" value={projectId} onValueChange={handleProjectChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Proje seç" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">Proje yok</SelectItem>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<SelectItem key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user