feat(tasks): personal-scope filter — mine / unassigned / mine+unassigned / all
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.
This commit is contained in:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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ı";
|
||||
|
||||
@@ -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) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.assigneeName && (
|
||||
{assignedToMe ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-primary/10 text-primary border-primary/30 gap-1 text-xs"
|
||||
>
|
||||
<UserCircle className="size-3" />
|
||||
Bana atanmış
|
||||
</Badge>
|
||||
) : task.assigneeName ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<UserCircle className="size-3" />
|
||||
{task.assigneeName}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground text-xs">
|
||||
Atanmamış
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) => (
|
||||
<TaskCard key={task.id} task={task} onEdit={onEdit} onDelete={onDelete} />
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{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<TaskRow[]>(initialTasks);
|
||||
const [activeId, setActiveId] = useState<string | null>(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 (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filter} onValueChange={(v) => setFilter(v as TaskFilter)}>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mine_or_unassigned">
|
||||
{FILTER_LABEL.mine_or_unassigned} ({filterCounts.mineOrUnassigned})
|
||||
</SelectItem>
|
||||
<SelectItem value="mine">
|
||||
{FILTER_LABEL.mine} ({filterCounts.mine})
|
||||
</SelectItem>
|
||||
<SelectItem value="unassigned">
|
||||
{FILTER_LABEL.unassigned} ({filterCounts.unassigned})
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{FILTER_LABEL.all} ({filterCounts.total})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => handleAdd("todo")}>
|
||||
<Plus className="size-4" />
|
||||
Yeni görev
|
||||
@@ -258,6 +322,7 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={(t) => setDeleting(t)}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -269,6 +334,7 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
isOverlay
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
|
||||
@@ -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<TaskFilter, string> = {
|
||||
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" },
|
||||
|
||||
@@ -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() {
|
||||
<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.
|
||||
Sürükle-bırak ile durumları değiştirin. Üstteki filtreden başkalarına atanmış
|
||||
görevleri de görebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user