From e03f315f3f31be7daefb744ee06bdd9d962ac7f2 Mon Sep 17 00:00:00 2001
From: Poyraz <78071274+poyrazavsever@users.noreply.github.com>
Date: Tue, 16 Jun 2026 16:38:37 +0300
Subject: [PATCH] feat: implement pending link and submit button components for
improved UX
- Added PendingLink component to provide loading feedback on navigation links.
- Introduced PendingSubmitButton component for forms to indicate submission status.
- Updated dashboard, projects, tasks, and clients pages to utilize new components for better user experience during navigation and form submissions.
- Enhanced task and project actions with optimistic updates and loading states.
- Improved overall performance and user feedback during data fetching and action processing.
---
app/(dashboard)/clients/clients-client.tsx | 11 +-
app/(dashboard)/dashboard-client.tsx | 10 +-
.../projects/[id]/project-detail-client.tsx | 120 ++++--
app/(dashboard)/projects/projects-client.tsx | 44 ++-
app/(dashboard)/tasks/tasks-client.tsx | 164 +++++++-
components/layout/dashboard-shell.tsx | 12 +-
components/ui/pending-link.tsx | 81 ++++
components/ui/pending-submit-button.tsx | 43 ++
docs/17-performans-ve-ux-plani.md | 370 ++++++++++++++++++
9 files changed, 773 insertions(+), 82 deletions(-)
create mode 100644 components/ui/pending-link.tsx
create mode 100644 components/ui/pending-submit-button.tsx
create mode 100644 docs/17-performans-ve-ux-plani.md
diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx
index 7f80a6c..4020563 100644
--- a/app/(dashboard)/clients/clients-client.tsx
+++ b/app/(dashboard)/clients/clients-client.tsx
@@ -6,6 +6,7 @@ import {
updateClientRecord,
updateClientPipelineStage,
} from "@/app/(dashboard)/clients/actions";
+import { PendingLink } from "@/components/ui/pending-link";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
@@ -325,9 +326,9 @@ function DraggableClientCard({
-
e.stopPropagation()}>
+
e.stopPropagation()} showSpinner>
{client.name}
-
+
e.stopPropagation()}>
} />
@@ -360,7 +361,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
{getInitials(client.name)}
-
{client.name}
+
{client.name}
{client.company_name || "Firma bilgisi yok"}
@@ -409,11 +410,11 @@ function ClientRow({ client }: { client: ClientListItem }) {
diff --git a/app/(dashboard)/dashboard-client.tsx b/app/(dashboard)/dashboard-client.tsx
index 55b09ff..20b1476 100644
--- a/app/(dashboard)/dashboard-client.tsx
+++ b/app/(dashboard)/dashboard-client.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import Link from "next/link";
+import { PendingLink } from "@/components/ui/pending-link";
import { Badge, Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, LineChart } from "recharts";
@@ -269,7 +269,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
{(data.projects || []).slice(0, 5).map((project) => (
-
+
@@ -282,7 +282,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
{project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'}
-
+
))}
{data.projects.length === 0 && (
Henüz proje yok.
@@ -300,7 +300,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
{(data.clients || []).length > 0 ? (data.clients || []).map((client) => (
-
+
{client.name.charAt(0).toUpperCase()}
@@ -311,7 +311,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
{new Date(client.created_at).toLocaleDateString("tr-TR", { month: "short", day: "numeric" })}
-
+
)) : (
Henüz müşteri yok.
)}
diff --git a/app/(dashboard)/projects/[id]/project-detail-client.tsx b/app/(dashboard)/projects/[id]/project-detail-client.tsx
index de70d60..95aa268 100644
--- a/app/(dashboard)/projects/[id]/project-detail-client.tsx
+++ b/app/(dashboard)/projects/[id]/project-detail-client.tsx
@@ -7,10 +7,11 @@ import {
updateProjectPlanningSectionRecord,
} from "@/app/(dashboard)/projects/actions";
import {
- completeTaskRecord,
createTaskRecord,
updateTaskStatusRecord,
} from "@/app/(dashboard)/tasks/actions";
+import { PendingLink } from "@/components/ui/pending-link";
+import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
@@ -25,6 +26,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
+ toast,
} from "poyraz-ui/molecules";
import {
ArrowLeft,
@@ -35,6 +37,7 @@ import {
FolderKanban,
KanbanSquare,
LayoutList,
+ Loader2,
Palette,
Pencil,
Plus,
@@ -43,8 +46,7 @@ import {
Trash2,
Wallet,
} from "lucide-react";
-import Link from "next/link";
-import { useState, useTransition, type DragEvent } from "react";
+import { useEffect, useState, useTransition, type DragEvent } from "react";
export type ProjectDetail = {
id: string;
@@ -197,10 +199,10 @@ export function ProjectDetailClient({
@@ -223,10 +225,14 @@ export function ProjectDetailClient({
{project.status !== "completed" ? (
) : null}
@@ -450,9 +456,12 @@ function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem
@@ -586,19 +595,48 @@ function TaskPanel({
}) {
const [view, setView] = useState<"list" | "kanban">("list");
const [localTasks, setLocalTasks] = useState(tasks);
+ const [pendingTaskIds, setPendingTaskIds] = useState
>(new Set());
const [, startTransition] = useTransition();
+ useEffect(() => {
+ setLocalTasks(tasks);
+ }, [tasks]);
+
function handleTaskStatusChange(taskId: string, status: ProjectDetailTaskItem["status"]) {
const previousTasks = localTasks;
+ setPendingTask(taskId, true);
setLocalTasks((currentTasks) =>
currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)),
);
startTransition(() => {
- void updateTaskStatusRecord(taskId, status, projectId).catch(() => {
- setLocalTasks(previousTasks);
- });
+ void updateTaskStatusRecord(taskId, status, projectId)
+ .catch((error) => {
+ setLocalTasks(previousTasks);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Görev durumu güncellenemedi.",
+ );
+ })
+ .finally(() => {
+ setPendingTask(taskId, false);
+ });
+ });
+ }
+
+ function setPendingTask(taskId: string, pending: boolean) {
+ setPendingTaskIds((current) => {
+ const next = new Set(current);
+
+ if (pending) {
+ next.add(taskId);
+ } else {
+ next.delete(taskId);
+ }
+
+ return next;
});
}
@@ -682,14 +720,21 @@ function TaskPanel({
{task.status !== "done" ? (
-
+ )}
+ {pendingTaskIds.has(task.id) ? "Tamamlanıyor" : "Tamamla"}
+
) : null}
@@ -700,8 +745,8 @@ function TaskPanel({
{localTasks.length > 0 && view === "kanban" ? (
) : null}
@@ -715,12 +760,12 @@ function TaskPanel({
}
function ProjectTaskKanban({
- projectId,
tasks,
+ pendingTaskIds,
onTaskStatusChange,
}: {
- projectId: string;
tasks: ProjectDetailTaskItem[];
+ pendingTaskIds: Set;
onTaskStatusChange: (taskId: string, status: ProjectDetailTaskItem["status"]) => void;
}) {
const columns = ["todo", "in_progress", "done"] as const;
@@ -784,19 +829,22 @@ function ProjectTaskKanban({
{task.priority}
{task.status !== "done" ? (
-
+ )}
+
) : null}
diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx
index 8897906..758097b 100644
--- a/app/(dashboard)/projects/projects-client.tsx
+++ b/app/(dashboard)/projects/projects-client.tsx
@@ -5,6 +5,8 @@ import {
createProjectRecord,
updateProjectRecord,
} from "@/app/(dashboard)/projects/actions";
+import { PendingLink } from "@/components/ui/pending-link";
+import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
@@ -36,8 +38,7 @@ import {
Brain,
Loader2,
} from "lucide-react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ChangeEvent } from "react";
export type ProjectClientOption = {
@@ -222,9 +223,21 @@ function ProjectCard({
clients: ProjectClientOption[];
}) {
const router = useRouter();
+ const pathname = usePathname();
+ const [isNavigating, setIsNavigating] = useState(false);
+ const detailHref = `/projects/${project.id}`;
+
+ useEffect(() => {
+ setIsNavigating(false);
+ }, [pathname]);
function goToProjectDetail() {
- router.push(`/projects/${project.id}`);
+ setIsNavigating(true);
+ router.push(detailHref);
+ }
+
+ function prefetchProjectDetail() {
+ router.prefetch(detailHref);
}
return (
@@ -232,8 +245,15 @@ function ProjectCard({
role="link"
tabIndex={0}
aria-label={`${project.name} detayına git`}
- className="cursor-pointer transition-colors hover:border-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ aria-busy={isNavigating}
+ className={
+ isNavigating
+ ? "relative cursor-progress opacity-80 transition-colors ring-2 ring-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ : "relative cursor-pointer transition-colors hover:border-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ }
onClick={goToProjectDetail}
+ onMouseEnter={prefetchProjectDetail}
+ onFocus={prefetchProjectDetail}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -242,6 +262,11 @@ function ProjectCard({
}}
>
+ {isNavigating ? (
+
+
+
+ ) : null}
@@ -353,24 +378,23 @@ function ProjectActions({
title="Detaya git"
aria-label="Detaya git"
>
-
+
-
+
) : null}
{project.status !== "completed" ? (
) : null}
diff --git a/app/(dashboard)/tasks/tasks-client.tsx b/app/(dashboard)/tasks/tasks-client.tsx
index cdd2cfe..a7f8238 100644
--- a/app/(dashboard)/tasks/tasks-client.tsx
+++ b/app/(dashboard)/tasks/tasks-client.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- completeTaskRecord,
createTaskRecord,
deleteTaskRecord,
updateTaskStatusRecord,
@@ -21,12 +20,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
+ toast,
} from "poyraz-ui/molecules";
import {
- CalendarDays,
CheckCircle2,
KanbanSquare,
LayoutList,
+ Loader2,
Pencil,
Plus,
Trash2,
@@ -86,6 +86,7 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
const [query, setQuery] = useState("");
const [projectFilter, setProjectFilter] = useState("__all");
const [view, setView] = useState<"list" | "kanban">("list");
+ const [pendingTaskIds, setPendingTaskIds] = useState
>(new Set());
const [, startTransition] = useTransition();
useEffect(() => {
@@ -95,14 +96,65 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
function handleTaskStatusChange(taskId: string, status: TaskListItem["status"]) {
const previousTasks = localTasks;
+ setPendingTask(taskId, true);
setLocalTasks((currentTasks) =>
currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)),
);
startTransition(() => {
- void updateTaskStatusRecord(taskId, status).catch(() => {
- setLocalTasks(previousTasks);
- });
+ void updateTaskStatusRecord(taskId, status)
+ .catch((error) => {
+ setLocalTasks(previousTasks);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Görev durumu güncellenemedi.",
+ );
+ })
+ .finally(() => {
+ setPendingTask(taskId, false);
+ });
+ });
+ }
+
+ function handleTaskDelete(taskId: string) {
+ const previousTasks = localTasks;
+ const task = localTasks.find((item) => item.id === taskId);
+ const formData = new FormData();
+ formData.set("id", taskId);
+
+ if (task?.project_id) {
+ formData.set("project_id", task.project_id);
+ }
+
+ setPendingTask(taskId, true);
+ setLocalTasks((currentTasks) => currentTasks.filter((item) => item.id !== taskId));
+
+ startTransition(() => {
+ void deleteTaskRecord(formData)
+ .catch((error) => {
+ setLocalTasks(previousTasks);
+ toast.error(
+ error instanceof Error ? error.message : "Görev silinemedi.",
+ );
+ })
+ .finally(() => {
+ setPendingTask(taskId, false);
+ });
+ });
+ }
+
+ function setPendingTask(taskId: string, pending: boolean) {
+ setPendingTaskIds((current) => {
+ const next = new Set(current);
+
+ if (pending) {
+ next.add(taskId);
+ } else {
+ next.delete(taskId);
+ }
+
+ return next;
});
}
@@ -208,12 +260,21 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
{filteredTasks.length > 0 ? (
view === "list" ? (
-
+
) : (
)
@@ -230,10 +291,16 @@ function TaskList({
tasks,
clients,
projects,
+ pendingTaskIds,
+ onTaskDelete,
+ onTaskStatusChange,
}: {
tasks: TaskListItem[];
clients: TaskRelationOption[];
projects: TaskRelationOption[];
+ pendingTaskIds: Set;
+ onTaskDelete: (taskId: string) => void;
+ onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
}) {
return (
@@ -247,7 +314,15 @@ function TaskList({
{tasks.map((task) => (
-
+
))}
@@ -259,10 +334,16 @@ function TaskRow({
task,
clients,
projects,
+ isPending,
+ onTaskDelete,
+ onTaskStatusChange,
}: {
task: TaskListItem;
clients: TaskRelationOption[];
projects: TaskRelationOption[];
+ isPending: boolean;
+ onTaskDelete: (taskId: string) => void;
+ onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
}) {
return (
@@ -286,7 +367,14 @@ function TaskRow({
{task.due_at ? formatDateTime(task.due_at) : "Yok"}
-
+
);
}
@@ -295,11 +383,15 @@ function TaskKanban({
tasks,
clients,
projects,
+ pendingTaskIds,
+ onTaskDelete,
onTaskStatusChange,
}: {
tasks: TaskListItem[];
clients: TaskRelationOption[];
projects: TaskRelationOption[];
+ pendingTaskIds: Set;
+ onTaskDelete: (taskId: string) => void;
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
}) {
const columns = ["todo", "in_progress", "done"] as const;
@@ -362,7 +454,15 @@ function TaskKanban({
{priorityLabels[task.priority]}
-
+
@@ -380,30 +480,52 @@ function TaskActions({
clients,
projects,
compact = false,
+ isPending,
+ onTaskDelete,
+ onTaskStatusChange,
}: {
task: TaskListItem;
clients: TaskRelationOption[];
projects: TaskRelationOption[];
compact?: boolean;
+ isPending: boolean;
+ onTaskDelete: (taskId: string) => void;
+ onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
}) {
return (
{task.status !== "done" ? (
-
- ) : null}
-
+ ) : null}
+
);
}
diff --git a/components/layout/dashboard-shell.tsx b/components/layout/dashboard-shell.tsx
index 300c6df..c4feffb 100644
--- a/components/layout/dashboard-shell.tsx
+++ b/components/layout/dashboard-shell.tsx
@@ -24,6 +24,7 @@ import {
SidebarUserProfile,
} from "poyraz-ui/organisms";
import { sidebarData } from "@/config/sidebar";
+import { PendingLink } from "@/components/ui/pending-link";
import { cn } from "@/lib/utils";
import { ChevronUp, LogOut, Menu, Settings } from "lucide-react";
import Image from "next/image";
@@ -158,13 +159,14 @@ function AppSidebar({
icon={Icon ? : undefined}
className={cn(isActive && "font-semibold")}
>
-
{item.title}
-
+
);
})}
@@ -214,10 +216,10 @@ function AppSidebar({
-
+
Ayarlar
-
+