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.
This commit is contained in:
Poyraz
2026-06-16 16:38:37 +03:00
parent 96eabeb48e
commit e03f315f3f
9 changed files with 773 additions and 82 deletions
+6 -5
View File
@@ -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({
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-3">
<div className="flex justify-between items-start mb-2">
<Link href={`/clients/${client.id}`} className="font-medium text-foreground hover:underline" onPointerDown={(e) => e.stopPropagation()}>
<PendingLink href={`/clients/${client.id}`} className="font-medium text-foreground hover:underline inline-flex items-center gap-1.5" onPointerDown={(e) => e.stopPropagation()} showSpinner>
{client.name}
</Link>
</PendingLink>
<div onPointerDown={(e) => e.stopPropagation()}>
<ClientDialog mode="edit" client={client} trigger={<Button variant="ghost" className="h-6 w-6 p-0"><Pencil className="h-3 w-3" /></Button>} />
</div>
@@ -360,7 +361,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
{getInitials(client.name)}
</div>
<div className="min-w-0">
<Link href={`/clients/${client.id}`} className="truncate font-medium text-foreground hover:underline block">{client.name}</Link>
<PendingLink href={`/clients/${client.id}`} className="truncate font-medium text-foreground hover:underline flex items-center gap-1.5" showSpinner>{client.name}</PendingLink>
<div className="truncate text-sm text-muted-foreground">
{client.company_name || "Firma bilgisi yok"}
</div>
@@ -409,11 +410,11 @@ function ClientRow({ client }: { client: ClientListItem }) {
</div>
<div className="flex justify-end gap-2">
<Link href={`/clients/${client.id}`}>
<PendingLink href={`/clients/${client.id}`} className="inline-flex" showSpinner>
<Button variant="ghost" className="h-9 w-9 p-0">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</PendingLink>
<ClientDialog mode="edit" client={client} trigger={<Button variant="outline" className="h-9 min-w-20 gap-2 px-3"><Pencil className="h-4 w-4" /> Düzenle</Button>} />
</div>
</div>
+5 -5
View File
@@ -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) {
</div>
<div className="space-y-4">
{(data.projects || []).slice(0, 5).map((project) => (
<Link key={project.id} href={`/projects/${project.id}`} className="flex items-center gap-3 hover:bg-muted/50 p-2 -mx-2 rounded-md transition-colors">
<PendingLink key={project.id} href={`/projects/${project.id}`} className="flex items-center gap-3 hover:bg-muted/50 p-2 -mx-2 rounded-md transition-colors" showSpinner>
<div className={`h-2 w-2 rounded-full ${project.status === 'completed' ? 'bg-emerald-500' : project.status === 'active' ? 'bg-blue-500' : 'bg-amber-500'}`} />
<div className="flex-1 flex justify-between items-center">
<div>
@@ -282,7 +282,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
{project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'}
</Badge>
</div>
</Link>
</PendingLink>
))}
{data.projects.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">Henüz proje yok.</div>
@@ -300,7 +300,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</div>
<div className="space-y-4">
{(data.clients || []).length > 0 ? (data.clients || []).map((client) => (
<Link key={client.id} href={`/clients/${client.id}`} className="flex items-center gap-3 hover:bg-muted/50 p-2 -mx-2 rounded-md transition-colors">
<PendingLink key={client.id} href={`/clients/${client.id}`} className="flex items-center gap-3 hover:bg-muted/50 p-2 -mx-2 rounded-md transition-colors" showSpinner>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary font-semibold text-xs">
{client.name.charAt(0).toUpperCase()}
</div>
@@ -311,7 +311,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<div className="text-xs text-muted-foreground">
{new Date(client.created_at).toLocaleDateString("tr-TR", { month: "short", day: "numeric" })}
</div>
</Link>
</PendingLink>
)) : (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">Henüz müşteri yok.</div>
)}
@@ -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({
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Button asChild variant="ghost" className="h-8 gap-2 px-0 text-muted-foreground">
<Link href="/projects">
<PendingLink href="/projects" className="flex items-center gap-2" showSpinner>
<ArrowLeft className="h-4 w-4" />
Projelere dön
</Link>
</PendingLink>
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
@@ -223,10 +225,14 @@ export function ProjectDetailClient({
{project.status !== "completed" ? (
<form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="outline" className="gap-2">
<CheckCircle2 className="h-4 w-4" />
<PendingSubmitButton
variant="outline"
className="gap-2"
idleIcon={<CheckCircle2 className="h-4 w-4" />}
pendingChildren="Tamamlanıyor"
>
Tamamla
</Button>
</PendingSubmitButton>
</form>
) : null}
</div>
@@ -450,9 +456,12 @@ function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem
<form action={deleteProjectPlanningSectionRecord}>
<input type="hidden" name="id" value={section.id} />
<input type="hidden" name="project_id" value={section.project_id} />
<Button type="submit" variant="outline" className="h-9 px-3 text-rose-600">
<Trash2 className="h-4 w-4" />
</Button>
<PendingSubmitButton
variant="outline"
className="h-9 px-3 text-rose-600"
idleIcon={<Trash2 className="h-4 w-4" />}
aria-label="Sil"
/>
</form>
</div>
</div>
@@ -586,19 +595,48 @@ function TaskPanel({
}) {
const [view, setView] = useState<"list" | "kanban">("list");
const [localTasks, setLocalTasks] = useState(tasks);
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(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({
</div>
<div className="flex justify-start lg:justify-end">
{task.status !== "done" ? (
<form action={completeTaskRecord}>
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="project_id" value={projectId} />
<Button type="submit" variant="outline" className="h-9 gap-2 px-3">
<Button
type="button"
variant="outline"
disabled={pendingTaskIds.has(task.id)}
aria-busy={pendingTaskIds.has(task.id)}
className="h-9 gap-2 px-3"
onClick={() => handleTaskStatusChange(task.id, "done")}
>
{pendingTaskIds.has(task.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
Tamamla
</Button>
</form>
)}
{pendingTaskIds.has(task.id) ? "Tamamlanıyor" : "Tamamla"}
</Button>
) : null}
</div>
</div>
@@ -700,8 +745,8 @@ function TaskPanel({
{localTasks.length > 0 && view === "kanban" ? (
<ProjectTaskKanban
projectId={projectId}
tasks={localTasks}
pendingTaskIds={pendingTaskIds}
onTaskStatusChange={handleTaskStatusChange}
/>
) : null}
@@ -715,12 +760,12 @@ function TaskPanel({
}
function ProjectTaskKanban({
projectId,
tasks,
pendingTaskIds,
onTaskStatusChange,
}: {
projectId: string;
tasks: ProjectDetailTaskItem[];
pendingTaskIds: Set<string>;
onTaskStatusChange: (taskId: string, status: ProjectDetailTaskItem["status"]) => void;
}) {
const columns = ["todo", "in_progress", "done"] as const;
@@ -784,19 +829,22 @@ function ProjectTaskKanban({
<div className="flex items-center justify-between gap-2">
<Badge className={priorityClasses[task.priority]}>{task.priority}</Badge>
{task.status !== "done" ? (
<form action={completeTaskRecord}>
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="project_id" value={projectId} />
<Button
type="submit"
variant="outline"
className="h-8 w-8 p-0"
title="Tamamla"
aria-label="Tamamla"
>
<Button
type="button"
variant="outline"
disabled={pendingTaskIds.has(task.id)}
aria-busy={pendingTaskIds.has(task.id)}
className="h-8 w-8 p-0"
title="Tamamla"
aria-label="Tamamla"
onClick={() => onTaskStatusChange(task.id, "done")}
>
{pendingTaskIds.has(task.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
</Button>
</form>
)}
</Button>
) : null}
</div>
</CardContent>
+34 -10
View File
@@ -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({
}}
>
<CardContent className="flex h-full flex-col gap-5 p-5">
{isNavigating ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-sm bg-background/70 backdrop-blur-[1px]">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : null}
<ProjectCover project={project} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -353,24 +378,23 @@ function ProjectActions({
title="Detaya git"
aria-label="Detaya git"
>
<Link href={`/projects/${project.id}`}>
<PendingLink href={`/projects/${project.id}`} className="flex h-full w-full items-center justify-center" showSpinner>
<Eye className="h-4 w-4" />
</Link>
</PendingLink>
</Button>
) : null}
<ProjectDialog mode="edit" project={project} clients={clients} iconOnly />
{project.status !== "completed" ? (
<form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} />
<Button
type="submit"
<PendingSubmitButton
variant="outline"
className="h-9 w-9 p-0"
title="Tamamla"
aria-label="Tamamla"
idleIcon={<CheckCircle2 className="h-4 w-4" />}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</PendingSubmitButton>
</form>
) : null}
</div>
+143 -21
View File
@@ -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<Set<string>>(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" ? (
<TaskList tasks={filteredTasks} clients={clients} projects={projects} />
<TaskList
tasks={filteredTasks}
clients={clients}
projects={projects}
pendingTaskIds={pendingTaskIds}
onTaskDelete={handleTaskDelete}
onTaskStatusChange={handleTaskStatusChange}
/>
) : (
<TaskKanban
tasks={filteredTasks}
clients={clients}
projects={projects}
pendingTaskIds={pendingTaskIds}
onTaskDelete={handleTaskDelete}
onTaskStatusChange={handleTaskStatusChange}
/>
)
@@ -230,10 +291,16 @@ function TaskList({
tasks,
clients,
projects,
pendingTaskIds,
onTaskDelete,
onTaskStatusChange,
}: {
tasks: TaskListItem[];
clients: TaskRelationOption[];
projects: TaskRelationOption[];
pendingTaskIds: Set<string>;
onTaskDelete: (taskId: string) => void;
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
}) {
return (
<div className="overflow-x-auto rounded-sm border border-border">
@@ -247,7 +314,15 @@ function TaskList({
</div>
<div className="divide-y divide-border">
{tasks.map((task) => (
<TaskRow key={task.id} task={task} clients={clients} projects={projects} />
<TaskRow
key={task.id}
task={task}
clients={clients}
projects={projects}
isPending={pendingTaskIds.has(task.id)}
onTaskDelete={onTaskDelete}
onTaskStatusChange={onTaskStatusChange}
/>
))}
</div>
</div>
@@ -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 (
<div className="grid gap-4 px-4 py-4 grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] items-center">
@@ -286,7 +367,14 @@ function TaskRow({
<div className={isOverdue(task) ? "text-sm font-medium text-rose-600" : "text-sm text-muted-foreground"}>
{task.due_at ? formatDateTime(task.due_at) : "Yok"}
</div>
<TaskActions task={task} clients={clients} projects={projects} />
<TaskActions
task={task}
clients={clients}
projects={projects}
isPending={isPending}
onTaskDelete={onTaskDelete}
onTaskStatusChange={onTaskStatusChange}
/>
</div>
);
}
@@ -295,11 +383,15 @@ function TaskKanban({
tasks,
clients,
projects,
pendingTaskIds,
onTaskDelete,
onTaskStatusChange,
}: {
tasks: TaskListItem[];
clients: TaskRelationOption[];
projects: TaskRelationOption[];
pendingTaskIds: Set<string>;
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({
<Badge className={priorityClasses[task.priority]}>
{priorityLabels[task.priority]}
</Badge>
<TaskActions task={task} clients={clients} projects={projects} compact />
<TaskActions
task={task}
clients={clients}
projects={projects}
compact
isPending={pendingTaskIds.has(task.id)}
onTaskDelete={onTaskDelete}
onTaskStatusChange={onTaskStatusChange}
/>
</div>
</CardContent>
</Card>
@@ -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 (
<div className={compact ? "flex justify-end gap-1" : "flex justify-start gap-2 lg:justify-end"}>
<TaskDialog mode="edit" task={task} clients={clients} projects={projects} />
{task.status !== "done" ? (
<form action={completeTaskRecord}>
<input type="hidden" name="id" value={task.id} />
<Button type="submit" variant="outline" className="h-9 min-w-24 gap-2 px-3">
<Button
type="button"
variant="outline"
disabled={isPending}
aria-busy={isPending}
className="h-9 min-w-24 gap-2 px-3"
onClick={() => onTaskStatusChange(task.id, "done")}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
{!compact ? "Tamamla" : null}
</Button>
</form>
) : null}
<form action={deleteTaskRecord}>
<input type="hidden" name="id" value={task.id} />
<Button type="submit" variant="outline" className="h-9 gap-2 px-3 text-rose-600">
<Trash2 className="h-4 w-4" />
)}
{!compact ? (isPending ? "Tamamlanıyor" : "Tamamla") : null}
</Button>
</form>
) : null}
<Button
type="button"
variant="outline"
disabled={isPending}
aria-busy={isPending}
className="h-9 gap-2 px-3 text-rose-600"
onClick={() => onTaskDelete(task.id)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
);
}
+7 -5
View File
@@ -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 ? <Icon className="h-4 w-4" /> : undefined}
className={cn(isActive && "font-semibold")}
>
<Link
<PendingLink
href={item.href || "#"}
onClick={onNavigate}
className="block w-full"
className="flex w-full items-center justify-between gap-2"
showSpinner
>
{item.title}
</Link>
</PendingLink>
</SidebarMenuItem>
);
})}
@@ -214,10 +216,10 @@ function AppSidebar({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" onClick={onNavigate} className="gap-2">
<PendingLink href="/settings" onClick={onNavigate} className="gap-2" showSpinner>
<Settings className="h-4 w-4" />
Ayarlar
</Link>
</PendingLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<form action={signOut}>
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { Loader2 } from "lucide-react";
import Link, { type LinkProps } from "next/link";
import { usePathname } from "next/navigation";
import {
useEffect,
useState,
type AnchorHTMLAttributes,
type MouseEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
type PendingLinkProps = LinkProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps | "href"> & {
children: ReactNode;
pendingClassName?: string;
showSpinner?: boolean;
spinnerClassName?: string;
};
export function PendingLink({
children,
className,
href,
onClick,
pendingClassName = "opacity-70",
showSpinner = false,
spinnerClassName,
target,
...props
}: PendingLinkProps) {
const pathname = usePathname();
const [pending, setPending] = useState(false);
useEffect(() => {
setPending(false);
}, [pathname]);
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
onClick?.(event);
if (
event.defaultPrevented ||
target === "_blank" ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.button !== 0
) {
return;
}
const hrefValue = typeof href === "string" ? href : href.pathname?.toString() ?? "";
const nextPath = hrefValue.split("?")[0].split("#")[0];
if (nextPath && nextPath !== pathname) {
setPending(true);
}
}
return (
<Link
{...props}
href={href}
target={target}
onClick={handleClick}
aria-busy={pending}
data-pending={pending ? "" : undefined}
className={cn(className, pending && pendingClassName)}
>
{children}
{pending && showSpinner ? (
<Loader2 className={cn("h-3.5 w-3.5 shrink-0 animate-spin", spinnerClassName)} />
) : null}
</Link>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { Loader2 } from "lucide-react";
import { Button } from "poyraz-ui/atoms";
import type { ComponentProps, ReactNode } from "react";
import { useFormStatus } from "react-dom";
import { cn } from "@/lib/utils";
type PendingSubmitButtonProps = ComponentProps<typeof Button> & {
idleIcon?: ReactNode;
pendingIcon?: ReactNode;
pendingChildren?: ReactNode;
};
export function PendingSubmitButton({
children,
className,
disabled,
idleIcon,
pendingChildren,
pendingIcon,
type = "submit",
...props
}: PendingSubmitButtonProps) {
const { pending } = useFormStatus();
const icon = pending
? pendingIcon ?? <Loader2 className="h-4 w-4 animate-spin" />
: idleIcon;
return (
<Button
{...props}
type={type}
disabled={disabled || pending}
aria-busy={pending}
className={cn(className)}
>
{icon}
{pending ? pendingChildren ?? children : children}
</Button>
);
}
+370
View File
@@ -0,0 +1,370 @@
---
title: Performans ve UX Planı
description: Neta içindeki yavaş navigasyon, aksiyon geri bildirimi ve sayfa bazlı performans iyileştirmeleri için faz bazlı çalışma planı.
order: 17
---
# Performans ve UX Planı
Bu planı, uygulamada hissedilen iki ana problemi çözmek için hazırladım:
1. Butona ya da linke tıklayınca kullanıcıya hemen geri bildirim gelmemesi.
2. Sayfa geçişlerinde ve bazı veri işlemlerinde uygulamanın tıklamayı almamış gibi hissettirmesi.
Mevcut kodda problem tek bir noktadan çıkmıyor. Next.js App Router, server component veri çekme sürecini tamamlamadan yeni sayfayı göstermiyor. Bu normal, fakat kullanıcıya loading state verilmediğinde bekleme süresi bozukluk gibi algılanıyor. Aynı şekilde bazı server action formları veritabanını güncelliyor ama UI tarafında optimistic state olmadığı için değişiklik geç görünüyor.
## Sayfa Bazlı İnceleme
### Dashboard
Dosyalar:
- `app/(dashboard)/page.tsx`
- `app/(dashboard)/dashboard-client.tsx`
Bulgular:
- Dashboard sayfasında sorgular sıralı çalışıyor. Görevler, projeler, finans, günlük kayıtları ve müşteriler peş peşe çekiliyor.
- Bazı sorgularda `select("*")` kullanılıyor. Dashboard için gereken kolonlar az olduğu halde tüm satır alanları taşınıyor.
- Hızlı ekle butonu ve sheet kaldırıldı. Bu alan artık sadece tarih filtresi taşıyor.
- Tamamlanan görev hesabında bazı yerlerde `completed`, bazı yerlerde `done` kullanımı var. Görev modülünün asıl statüsü `done`; bu tutarsızlık metrikleri yanlış gösterebilir.
İyileştirme yönü:
- Dashboard veri çekimi `Promise.all` ile paralel hale getirilmeli.
- `select("*")` yerine sadece gerekli kolonlar seçilmeli.
- Görev statüsü hesabı `done` standardına çekilmeli.
- Dashboard için lightweight loading skeleton eklenmeli.
### Görevler
Dosyalar:
- `app/(dashboard)/tasks/page.tsx`
- `app/(dashboard)/tasks/tasks-client.tsx`
- `app/(dashboard)/tasks/actions.ts`
Bulgular:
- Görev ekleme dialogu mobilde scroll problemi yaşıyordu. Dialog içeriği viewport yüksekliğine göre sınırlandı, gövde scroll edilebilir hale getirildi ve submit butonu footer içinde erişilebilir tutuldu.
- Kanban sürükle-bırak akışında optimistic update var.
- Liste görünümünde `Tamamla` ve `Sil` butonları doğrudan server action formu kullanıyor. Bu yüzden veritabanı güncellense bile UI, route refresh/revalidation tamamlanana kadar değişmiyor.
- `TaskList` şu anda `handleTaskStatusChange` fonksiyonunu almıyor. Bu yüzden liste görünümü ile kanban görünümü aynı davranışı paylaşmıyor.
- Form submitlerinde pending state her yerde aynı değil. Bazı dialoglarda `isSubmitting` var, satır aksiyonlarında yok.
İyileştirme yönü:
- `TaskList` ve `TaskRow` içine optimistic status handler geçirilmeli.
- `Tamamla` aksiyonu liste ve kanban görünümünde aynı client-side optimistic akışı kullanmalı.
- Satır bazlı butonlarda pending spinner/disabled state olmalı.
- Hata durumunda task eski haline dönmeli ve kullanıcıya toast gösterilmeli.
- Silme aksiyonunda da optimistic remove + rollback uygulanmalı.
### Projeler
Dosyalar:
- `app/(dashboard)/projects/page.tsx`
- `app/(dashboard)/projects/projects-client.tsx`
- `app/(dashboard)/projects/actions.ts`
Bulgular:
- Proje kartları `router.push` ile detay sayfasına gidiyor. Tıklama anında pending state olmadığı için 2-3 saniyelik veri çekme süresi kullanıcıya bozuk hissettiriyor.
- Liste görünümündeki detay butonu `Link` kullanıyor, fakat loading feedback yine yok.
- Proje kapak görselleri için her sayfa açılışında storage üzerinden signed URL üretiliyor. Proje sayısı arttıkça bu işlem liste açılışını yavaşlatabilir.
- Task istatistiği için tüm görevlerin `project_id, status` kolonları çekiliyor. Veri büyüdükçe bu iş RPC ya da aggregate sorguya alınmalı.
- Proje tamamlama aksiyonu server action formu olarak çalışıyor, fakat satır/kart üzerinde pending veya optimistic durum yok.
İyileştirme yönü:
- Proje kartı `router.push` yerine erişilebilir bir `Link` yapısına dönüştürülmeli veya `PendingLink` wrapper ile route pending feedback almalı.
- Kart tıklanınca kart üzerinde kısa süreli loading/disabled state gösterilmeli.
- `router.prefetch` veya `Link` prefetch davranışı kritik detay sayfaları için kullanılmalı.
- Signed URL üretimi limitlenmeli, cache süresi netleştirilmeli veya sadece görünür kartlar için yapılmalı.
- Proje tamamlama aksiyonu için optimistic status/progress güncellemesi eklenmeli.
### Proje Detayı
Dosyalar:
- `app/(dashboard)/projects/[id]/page.tsx`
- `app/(dashboard)/projects/[id]/project-detail-client.tsx`
Bulgular:
- Detay sayfası aynı anda proje, planlama alanları, görevler, finans kayıtları ve revizyonları çekiyor. Bu doğru şekilde `Promise.all` ile yapılmış, fakat sayfa yine ağır olabilir.
- Kapak görseli için signed URL ayrıca üretiliyor.
- Görevler sekmesinde kanban sürükle-bırak optimistic çalışıyor.
- Aynı sekmedeki `Tamamla` butonu ise doğrudan server action formu. Bu yüzden butona basınca local task state güncellenmiyor.
- Planlama alanı silme, proje tamamlama ve proje ayarları işlemlerinde standart pending/toast yok.
- Sekmeler client-side olduğu için hızlı, fakat ilk sayfa yükü bütün sekme verilerini baştan taşıyor.
İyileştirme yönü:
- Proje görevlerindeki `Tamamla` butonu `handleTaskStatusChange` akışını kullanmalı.
- Proje detayında task list ve task kanban aynı optimistic action modelini paylaşmalı.
- Ağır sekmeler için ileride route segment veya lazy data yaklaşımı değerlendirilmeli.
- Planlama alanı silme ve proje tamamlama için ortak pending button kullanılmalı.
### Müşteriler
Dosyalar:
- `app/(dashboard)/clients/page.tsx`
- `app/(dashboard)/clients/clients-client.tsx`
- `app/(dashboard)/clients/[id]/page.tsx`
Bulgular:
- Pipeline sürükle-bırak optimistic update yapıyor. Bu iyi bir örnek.
- Müşteri kart ve satır linklerinde pending feedback yok.
- Müşteri detay sayfasında müşteri ve activity verisi sıralı çekiliyor. Activity sorgusu client idye bağlı olduğu için pratikte sorun küçük, fakat sayfa açılışında loading skeleton yok.
- Müşteri dialoglarında submit pending var, fakat hata toastu standart değil.
İyileştirme yönü:
- Müşteri linkleri `PendingLink` ile tıklama feedbacki almalı.
- Detail sayfasına skeleton eklenmeli.
- Dialog submit hata/success davranışı proje dialoglarıyla aynı standarda çekilmeli.
### Finans
Dosyalar:
- `app/(dashboard)/finance/page.tsx`
- `app/(dashboard)/finance/finance-client.tsx`
- `app/(dashboard)/finance/actions.ts`
Bulgular:
- Sayfa veri çekimi paralel yapılıyor.
- Transaction silme server action formu ile çalışıyor, fakat satır bazlı pending feedback yok.
- Dialog scroll/pending deseni görev sayfasındaki yeni mobil pattern ile aynı standarda çekilmeli.
- Veri büyüdüğünde tüm finans kayıtlarını çekmek yerine dönem filtresi veya pagination gerekebilir.
İyileştirme yönü:
- Silme ve form submitlerinde ortak pending button kullanılmalı.
- Listeye dönem filtresi veya pagination eklenmeli.
- Mobil dialog düzeni standart hale getirilmeli.
### Takvim
Dosyalar:
- `app/(dashboard)/calendar/page.tsx`
- `app/(dashboard)/calendar/calendar-client.tsx`
- `app/(dashboard)/calendar/actions.ts`
Bulgular:
- Sayfa veri çekimi paralel yapılıyor.
- Etkinlik silme server action formu ile çalışıyor, pending feedback yok.
- Tüm etkinlikler çekiliyor. Veri büyüdüğünde ay bazlı sorgu daha doğru olur.
İyileştirme yönü:
- Ay görünümüne göre tarih aralıklı sorgu planlanmalı.
- Silme ve kayıt işlemleri ortak pending/toast modeline alınmalı.
- Takvim sayfası için skeleton eklenmeli.
### Analizler
Dosyalar:
- `app/(dashboard)/analytics/page.tsx`
- `app/(dashboard)/analytics/analytics-client.tsx`
Bulgular:
- Görev, proje ve finans sorguları sıralı çalışıyor.
- Üç tabloda da `select("*")` kullanılıyor.
- Analiz ekranı genellikle aggregate veri ister; tüm satırları taşımak gereksiz büyüyebilir.
İyileştirme yönü:
- Sorgular `Promise.all` ile paralelleştirilmeli.
- Gerekli kolonlara düşürülmeli.
- Orta vadede Supabase RPC veya view ile aggregate veri dönülmeli.
- Sayfa için skeleton eklenmeli.
### Layout ve Navigasyon
Dosyalar:
- `app/(dashboard)/layout.tsx`
- `components/layout/dashboard-shell.tsx`
- `config/sidebar.ts`
Bulgular:
- Dashboard layout her route geçişinde auth user ve profil bilgisini kontrol ediyor.
- Sidebar linklerinde route geçiş pending feedbacki yok.
- Mobil sidebar linke tıklayınca kapanıyor ama route yüklenene kadar ana içerik eski kalıyor.
- App Router yeni routeun server component payloadunu beklerken kullanıcıya global bir progress göstergesi verilmediği için tıklama çalışmamış gibi hissediliyor.
İyileştirme yönü:
- Global route pending feedback eklenmeli.
- Sidebar linkleri aktif/pending durumunu gösterecek wrapper ile değiştirilmeli.
- `app/(dashboard)/loading.tsx` ve kritik route loading dosyaları eklenmeli.
- Layout profil sorgusu mümkünse cache/helper seviyesinde sadeleştirilmeli.
## Faz Bazlı Plan
### Faz 1: Acil UX Düzeltmeleri
Amaç: Butona basınca anında tepki almak.
Yapılacaklar:
- `PendingSubmitButton` bileşeni oluşturulacak.
- Server action formlarında `useFormStatus` ile buton loading/disabled hale getirilecek.
- Görev listesi `Tamamla` butonu optimistic update kullanacak.
- Proje detayındaki görev `Tamamla` butonları aynı optimistic update modeline alınacak.
- Silme işlemlerinde satır bazlı loading ve mümkün olan yerlerde optimistic remove eklenecek.
- Hata durumunda rollback + toast standardı uygulanacak.
Öncelikli dosyalar:
- `app/(dashboard)/tasks/tasks-client.tsx`
- `app/(dashboard)/projects/[id]/project-detail-client.tsx`
- `app/(dashboard)/tasks/actions.ts`
Kabul kriteri:
- Görev listesinde `Tamamla` tıklandığında satır hemen tamamlanmış görünmeli.
- Buton işlem süresince disabled/loading olmalı.
- Hata olursa görev eski statüsüne dönmeli.
### Faz 2: Navigasyon Feedbacki
Amaç: Linke tıklandığında kullanıcı anında geçişin başladığını görmeli.
Yapılacaklar:
- `PendingLink` veya `NavigationProgressProvider` oluşturulacak.
- Pathname değişene kadar linkte pending state tutulacak.
- Sidebar linkleri, dashboard recent item linkleri, müşteri/proje liste linkleri bu wrapper ile güncellenecek.
- Proje kartlarındaki `router.push` kullanımı ya `Link` tabanlı karta çevrilecek ya da explicit pending state ile desteklenecek.
- Kritik kart/detail linklerinde hover/focus sırasında prefetch yapılacak.
Öncelikli dosyalar:
- `components/layout/dashboard-shell.tsx`
- `app/(dashboard)/projects/projects-client.tsx`
- `app/(dashboard)/clients/clients-client.tsx`
- `app/(dashboard)/dashboard-client.tsx`
Kabul kriteri:
- Sidebar veya proje kartına tıklandığında anında visual feedback görünmeli.
- Kullanıcı eski sayfada beklerken tıklama boşa gitmiş gibi hissetmemeli.
### Faz 3: Route Loading Skeletonları
Amaç: Server component verisi beklenirken boş veya donmuş ekran hissini kaldırmak.
Yapılacaklar:
- `app/(dashboard)/loading.tsx` eklenecek.
- Kritik sayfalar için özel loading skeletonları hazırlanacak:
- `app/(dashboard)/projects/loading.tsx`
- `app/(dashboard)/projects/[id]/loading.tsx`
- `app/(dashboard)/tasks/loading.tsx`
- `app/(dashboard)/clients/loading.tsx`
- `app/(dashboard)/analytics/loading.tsx`
- Skeletonlar gerçek sayfa layoutuna benzeyecek, sadece placeholder gösterecek.
Kabul kriteri:
- Sayfa geçişinde eski sayfada donuk bekleme yerine kontrollü loading görünmeli.
- Mobilde skeleton içerik taşmamalı.
### Faz 4: Veri Çekme Optimizasyonu
Amaç: Sayfa açılışlarını gerçekten hızlandırmak.
Yapılacaklar:
- Dashboard sorguları paralel hale getirilecek.
- Analytics sorguları paralel hale getirilecek.
- Dashboard ve analytics tarafındaki `select("*")` kullanımları kaldırılacak.
- Projeler sayfasında task istatistiği aggregate sorgu/RPC ile alınacak.
- Finans, takvim ve analytics için pagination veya tarih aralığı filtresi planlanacak.
- Proje kapak görsellerinde signed URL üretimi gözden geçirilecek; gerekirse sadece ihtiyaç duyulan görseller için URL üretilecek.
Öncelikli dosyalar:
- `app/(dashboard)/page.tsx`
- `app/(dashboard)/analytics/page.tsx`
- `app/(dashboard)/projects/page.tsx`
- `supabase/setup.sql`
Kabul kriteri:
- Dashboard ve analytics route response süreleri ölçülebilir şekilde düşmeli.
- Gereksiz kolon taşınmamalı.
- Proje listesi fazla görselli projelerde daha stabil açılmalı.
### Faz 5: Mutation Standardı
Amaç: Tüm ekleme, güncelleme, silme ve tamamlama işlemleri aynı hissi vermeli.
Yapılacaklar:
- Ortak action button kalıbı oluşturulacak.
- Dialog submitlerinde success/error toast standardı uygulanacak.
- Server action dönüşleri mümkünse `{ ok, message }` yapısına yaklaştırılacak.
- `revalidatePath` kapsamı her action için tekrar kontrol edilecek.
- `done` ve `completed` statü kullanımı standardize edilecek.
Kabul kriteri:
- Görev, proje, müşteri, finans, takvim ve günlük formları aynı pending/hata/success davranışını göstermeli.
- Statü değişimleri UIda tutarlı görünmeli.
### Faz 6: Mobil ve Touch Deneyimi
Amaç: Mobilde form, dialog ve kanban akışlarını güvenli hale getirmek.
Yapılacaklar:
- Görev dialogunda uygulanan scroll pattern diğer uzun dialoglara taşınacak.
- Proje ekleme, finans ekleme, takvim etkinliği ve günlük dialogları mobilde test edilecek.
- Drag/drop kullanılan alanlarda mobil için butonlu alternatif durum değişimi eklenecek.
- Buton hit area ve sticky footer davranışları kontrol edilecek.
Kabul kriteri:
- Mobilde hiçbir uzun form submit butonunu viewport dışında bırakmamalı.
- Drag/drop olmadan da statü değiştirilebilmeli.
### Faz 7: Test ve Ölçüm
Amaç: İyileştirmelerin gerçekten çalıştığını doğrulamak.
Yapılacaklar:
- `npm run build` çalıştırılacak.
- Dashboard, görevler, projeler, proje detayı, müşteriler, finans ve takvim için manuel smoke test yapılacak.
- Mobil viewportta görev/proje/finans dialogları test edilecek.
- Route geçişleri için basit ölçüm notu tutulacak.
- Kritik aksiyonlarda hata senaryosu test edilecek.
Kabul kriteri:
- Build başarılı olmalı.
- Görev tamamla, proje detayına gitme ve sidebar navigasyonu görsel feedback vermeli.
- Mobilde görev formu scroll ve submit açısından çalışmalı.
## Uygulama Sırası
Önce Faz 1 uygulanmalı. Çünkü kullanıcının “Göreve tamamla dediğimde aksiyon almıyor gibi” hissi doğrudan buradan geliyor.
Ardından Faz 2 ve Faz 3 beraber ilerlemeli. Navigasyon pending statei ve route skeletonları aynı problemi iki farklı anda çözüyor: tıklama anı ve route veri bekleme anı.
Faz 4 daha teknik performans tarafı. İlk üç faz UX hissini hızlıca düzeltir, Faz 4 gerçek veri süresini azaltır.
Faz 5 ve Faz 6 uygulamanın tamamına kalite standardı yayar. Faz 7 her fazdan sonra küçük parçalar halinde de çalıştırılabilir, fakat büyük UX değişimlerinden sonra mutlaka yapılmalı.