From 858c916d958a9862f77c4c3dbe80ca636a84197d Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 06:28:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20personal-scope=20filter=20?= =?UTF-8?q?=E2=80=94=20mine=20/=20unassigned=20/=20mine+unassigned=20/=20a?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant teams need a way to focus on their own work without seeing the whole team's board. Added a server-side filter: - /tasks?view=mine_or_unassigned (default): own assigned + unassigned - /tasks?view=mine: only assigned to me - /tasks?view=unassigned: claimable, unassigned tasks - /tasks?view=all: full team board (managers / overview) Implementation: - Server page reads ?view= query, validates against allowed list, filters tasks before passing to the client. Also computes total counts (across all rows) for each filter so the dropdown can show '(N)' badges that don't change when the user switches views. - TasksBoard top-bar gets a Select that updates the URL via router.push() (preserves Next's full SSR + revalidatePath flow). - Default-filter URL drops the ?view= param to keep the canonical /tasks URL clean. Card visual cues: - Tasks assigned to current user get a primary-tinted ring + a 'Bana atanmış' badge (replaces the assignee name pill). - Unassigned tasks get a dashed border + 'Atanmamış' badge so they visually invite ownership. Dashboard: - 'Açık görevler' metric is now 'Açık görevlerim' — sums only own + unassigned tasks. Subtext updates accordingly. Same scoping for urgent count. Managers can still see team-wide via /tasks?view=all. --- .../dashboard/components/metrics.tsx | 8 ++- src/app/(dashboard)/dashboard/page.tsx | 2 +- .../tasks/components/task-card.tsx | 21 +++++- .../tasks/components/tasks-board.tsx | 72 ++++++++++++++++++- src/app/(dashboard)/tasks/components/types.ts | 9 +++ src/app/(dashboard)/tasks/page.tsx | 41 ++++++++++- src/lib/appwrite/dashboard-queries.ts | 15 ++-- 7 files changed, 153 insertions(+), 15 deletions(-) diff --git a/src/app/(dashboard)/dashboard/components/metrics.tsx b/src/app/(dashboard)/dashboard/components/metrics.tsx index 2315a4e..42a6a35 100644 --- a/src/app/(dashboard)/dashboard/components/metrics.tsx +++ b/src/app/(dashboard)/dashboard/components/metrics.tsx @@ -55,10 +55,14 @@ export function Metrics({ data }: { data: DashboardData["metrics"] }) { tone: data.overdueCount > 0 ? "warning" : "default", }, { - label: "Açık görevler", + label: "Açık görevlerim", value: String(data.openTasks), sub: - data.urgentTasks > 0 ? `${data.urgentTasks} acil` : "Acil görev yok", + data.urgentTasks > 0 + ? `${data.urgentTasks} acil` + : data.openTasks === 0 + ? "Hepsi tamam" + : "Atanmış + atanmamış", icon: CheckSquare, tone: data.urgentTasks > 0 ? "warning" : "default", }, diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 9195007..fa947f3 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -14,7 +14,7 @@ export default async function DashboardPage() { const ctx = await getActiveContext(); if (!ctx) redirect("/onboarding"); - const data = await getDashboardData(ctx.tenantId); + const data = await getDashboardData(ctx.tenantId, ctx.user.id); const firstName = ctx.user.name?.split(" ")[0] ?? ""; const companyName = ctx.settings?.companyName ?? "Çalışma alanı"; diff --git a/src/app/(dashboard)/tasks/components/task-card.tsx b/src/app/(dashboard)/tasks/components/task-card.tsx index 2f44810..e78c04a 100644 --- a/src/app/(dashboard)/tasks/components/task-card.tsx +++ b/src/app/(dashboard)/tasks/components/task-card.tsx @@ -22,9 +22,12 @@ type Props = { onEdit: (task: TaskRow) => void; onDelete: (task: TaskRow) => void; isOverlay?: boolean; + currentUserId?: string; }; -export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) { +export function TaskCard({ task, onEdit, onDelete, isOverlay, currentUserId }: Props) { + const assignedToMe = currentUserId && task.assigneeId === currentUserId; + const unassigned = !task.assigneeId; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, data: { type: "task", status: task.status } }); @@ -42,6 +45,8 @@ export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) { style={style} className={cn( "bg-card group rounded-lg border p-3 shadow-sm", + assignedToMe && "border-primary/40 ring-primary/20 ring-1", + unassigned && "border-dashed", isDragging && "opacity-30", isOverlay && "rotate-3 shadow-xl", )} @@ -110,11 +115,23 @@ export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) { )} - {task.assigneeName && ( + {assignedToMe ? ( + + + Bana atanmış + + ) : task.assigneeName ? ( {task.assigneeName} + ) : ( + + Atanmamış + )} diff --git a/src/app/(dashboard)/tasks/components/tasks-board.tsx b/src/app/(dashboard)/tasks/components/tasks-board.tsx index 6ad78d7..40c14d5 100644 --- a/src/app/(dashboard)/tasks/components/tasks-board.tsx +++ b/src/app/(dashboard)/tasks/components/tasks-board.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { DndContext, type DragEndEvent, @@ -17,6 +18,13 @@ import { Loader2, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Dialog, DialogContent, @@ -33,16 +41,28 @@ import { TaskFormSheet } from "./task-form-sheet"; import { COLUMNS, type Customer, + FILTER_LABEL, + type TaskFilter, type TaskRow, type TaskStatus, type TeamMember, } from "./types"; import { useTransition } from "react"; +type FilterCounts = { + total: number; + mine: number; + unassigned: number; + mineOrUnassigned: number; +}; + type Props = { tasks: TaskRow[]; customers: Customer[]; teamMembers: TeamMember[]; + currentUserId: string; + filter: TaskFilter; + filterCounts: FilterCounts; }; function Column({ @@ -52,6 +72,7 @@ function Column({ onAdd, onEdit, onDelete, + currentUserId, }: { status: TaskStatus; title: string; @@ -59,6 +80,7 @@ function Column({ onAdd: (status: TaskStatus) => void; onEdit: (task: TaskRow) => void; onDelete: (task: TaskRow) => void; + currentUserId: string; }) { const { setNodeRef, isOver } = useDroppableColumn(status); @@ -94,7 +116,13 @@ function Column({ strategy={verticalListSortingStrategy} > {tasks.map((task) => ( - + ))} {tasks.length === 0 && ( @@ -112,7 +140,14 @@ function useDroppableColumn(status: TaskStatus) { }); } -export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Props) { +export function TasksBoard({ + tasks: initialTasks, + customers, + teamMembers, + currentUserId, + filter, + filterCounts, +}: Props) { const [tasks, setTasks] = useState(initialTasks); const [activeId, setActiveId] = useState(null); const [formOpen, setFormOpen] = useState(false); @@ -230,9 +265,38 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop }); }; + const router = useRouter(); + const setFilter = (value: TaskFilter) => { + const params = new URLSearchParams(); + if (value !== "mine_or_unassigned") params.set("view", value); + router.push(`/tasks${params.size ? `?${params}` : ""}`); + }; + return ( <> -
+
+
+ +
+
@@ -269,6 +334,7 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop onEdit={() => {}} onDelete={() => {}} isOverlay + currentUserId={currentUserId} /> )} diff --git a/src/app/(dashboard)/tasks/components/types.ts b/src/app/(dashboard)/tasks/components/types.ts index 3a45594..45755e6 100644 --- a/src/app/(dashboard)/tasks/components/types.ts +++ b/src/app/(dashboard)/tasks/components/types.ts @@ -18,6 +18,15 @@ export type TaskRow = { export type Customer = { id: string; name: string }; export type TeamMember = { id: string; name: string }; +export type TaskFilter = "all" | "mine" | "unassigned" | "mine_or_unassigned"; + +export const FILTER_LABEL: Record = { + all: "Hepsi", + mine: "Bana atanmış", + unassigned: "Atanmamış", + mine_or_unassigned: "Bana atanmış + Atanmamış", +}; + export const COLUMNS: { key: TaskStatus; title: string }[] = [ { key: "backlog", title: "Beklemede" }, { key: "todo", title: "Yapılacak" }, diff --git a/src/app/(dashboard)/tasks/page.tsx b/src/app/(dashboard)/tasks/page.tsx index b3f568e..d616e8a 100644 --- a/src/app/(dashboard)/tasks/page.tsx +++ b/src/app/(dashboard)/tasks/page.tsx @@ -6,12 +6,19 @@ 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 type { TaskFilter } from "./components/types"; export const metadata: Metadata = { title: "İşletmem — Görevler", }; -export default async function TasksPage() { +const ALLOWED_FILTERS: TaskFilter[] = ["all", "mine", "unassigned", "mine_or_unassigned"]; + +export default async function TasksPage({ + searchParams, +}: { + searchParams: Promise<{ view?: string }>; +}) { let ctx; try { ctx = await requireTenant(); @@ -19,11 +26,35 @@ export default async function TasksPage() { redirect("/onboarding"); } - const [tasks, customers] = await Promise.all([ + const sp = await searchParams; + const filter: TaskFilter = + (ALLOWED_FILTERS as string[]).includes(sp.view ?? "") + ? (sp.view as TaskFilter) + : "mine_or_unassigned"; + + const [allTasks, customers] = await Promise.all([ listTasks(ctx.tenantId), listCustomers(ctx.tenantId), ]); + const tasks = allTasks.filter((t) => { + const assignee = t.assigneeId ?? ""; + if (filter === "mine") return assignee === ctx.user.id; + if (filter === "unassigned") return !assignee; + if (filter === "mine_or_unassigned") + return !assignee || assignee === ctx.user.id; + return true; + }); + + const filterCounts = { + total: allTasks.length, + mine: allTasks.filter((t) => t.assigneeId === ctx.user.id).length, + unassigned: allTasks.filter((t) => !t.assigneeId).length, + mineOrUnassigned: allTasks.filter( + (t) => !t.assigneeId || t.assigneeId === ctx.user.id, + ).length, + }; + let teamMembers: { id: string; name: string }[] = []; try { const { teams } = createAdminClient(); @@ -45,7 +76,8 @@ export default async function TasksPage() {

{ctx.settings?.companyName ?? "Çalışma alanı"}

Görevler

- Sürükle-bırak ile durumları değiştirin, ekibinizle iş takibini kolaylaştırın. + Sürükle-bırak ile durumları değiştirin. Üstteki filtreden başkalarına atanmış + görevleri de görebilirsiniz.

@@ -65,6 +97,9 @@ export default async function TasksPage() { }))} customers={customers.map((c) => ({ id: c.$id, name: c.name }))} teamMembers={teamMembers} + currentUserId={ctx.user.id} + filter={filter} + filterCounts={filterCounts} /> ); diff --git a/src/lib/appwrite/dashboard-queries.ts b/src/lib/appwrite/dashboard-queries.ts index 832f70e..72d1ae8 100644 --- a/src/lib/appwrite/dashboard-queries.ts +++ b/src/lib/appwrite/dashboard-queries.ts @@ -47,7 +47,10 @@ function monthLabel(d: Date): string { return MONTH_SHORT[d.getMonth()]; } -export async function getDashboardData(tenantId: string): Promise { +export async function getDashboardData( + tenantId: string, + currentUserId?: string, +): Promise { const { tablesDB } = createAdminClient(); const [customers, invoices, financeEntries, tasks, services] = await Promise.all([ @@ -129,10 +132,14 @@ export async function getDashboardData(tenantId: string): Promise let openTasks = 0; let urgentTasks = 0; for (const t of taskList) { - if ((t.status ?? "todo") !== "done") { - openTasks += 1; - if ((t.priority ?? "medium") === "urgent") urgentTasks += 1; + if ((t.status ?? "todo") === "done") continue; + // Personal scope: own assigned + unassigned. Falls back to all if no userId. + if (currentUserId) { + const assignee = t.assigneeId ?? ""; + if (assignee && assignee !== currentUserId) continue; } + openTasks += 1; + if ((t.priority ?? "medium") === "urgent") urgentTasks += 1; } const activeCustomers = customerList.filter((c) => (c.status ?? "active") === "active").length;