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",
|
tone: data.overdueCount > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Açık görevler",
|
label: "Açık görevlerim",
|
||||||
value: String(data.openTasks),
|
value: String(data.openTasks),
|
||||||
sub:
|
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,
|
icon: CheckSquare,
|
||||||
tone: data.urgentTasks > 0 ? "warning" : "default",
|
tone: data.urgentTasks > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function DashboardPage() {
|
|||||||
const ctx = await getActiveContext();
|
const ctx = await getActiveContext();
|
||||||
if (!ctx) redirect("/onboarding");
|
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 firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||||
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
|
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ type Props = {
|
|||||||
onEdit: (task: TaskRow) => void;
|
onEdit: (task: TaskRow) => void;
|
||||||
onDelete: (task: TaskRow) => void;
|
onDelete: (task: TaskRow) => void;
|
||||||
isOverlay?: boolean;
|
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 } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: task.id, data: { type: "task", status: task.status } });
|
useSortable({ id: task.id, data: { type: "task", status: task.status } });
|
||||||
|
|
||||||
@@ -42,6 +45,8 @@ export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) {
|
|||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card group rounded-lg border p-3 shadow-sm",
|
"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",
|
isDragging && "opacity-30",
|
||||||
isOverlay && "rotate-3 shadow-xl",
|
isOverlay && "rotate-3 shadow-xl",
|
||||||
)}
|
)}
|
||||||
@@ -110,11 +115,23 @@ export function TaskCard({ task, onEdit, onDelete, isOverlay }: Props) {
|
|||||||
</Badge>
|
</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">
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
<UserCircle className="size-3" />
|
<UserCircle className="size-3" />
|
||||||
{task.assigneeName}
|
{task.assigneeName}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground text-xs">
|
||||||
|
Atanmamış
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -17,6 +18,13 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,16 +41,28 @@ import { TaskFormSheet } from "./task-form-sheet";
|
|||||||
import {
|
import {
|
||||||
COLUMNS,
|
COLUMNS,
|
||||||
type Customer,
|
type Customer,
|
||||||
|
FILTER_LABEL,
|
||||||
|
type TaskFilter,
|
||||||
type TaskRow,
|
type TaskRow,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TeamMember,
|
type TeamMember,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
|
|
||||||
|
type FilterCounts = {
|
||||||
|
total: number;
|
||||||
|
mine: number;
|
||||||
|
unassigned: number;
|
||||||
|
mineOrUnassigned: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tasks: TaskRow[];
|
tasks: TaskRow[];
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
teamMembers: TeamMember[];
|
teamMembers: TeamMember[];
|
||||||
|
currentUserId: string;
|
||||||
|
filter: TaskFilter;
|
||||||
|
filterCounts: FilterCounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Column({
|
function Column({
|
||||||
@@ -52,6 +72,7 @@ function Column({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
currentUserId,
|
||||||
}: {
|
}: {
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -59,6 +80,7 @@ function Column({
|
|||||||
onAdd: (status: TaskStatus) => void;
|
onAdd: (status: TaskStatus) => void;
|
||||||
onEdit: (task: TaskRow) => void;
|
onEdit: (task: TaskRow) => void;
|
||||||
onDelete: (task: TaskRow) => void;
|
onDelete: (task: TaskRow) => void;
|
||||||
|
currentUserId: string;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppableColumn(status);
|
const { setNodeRef, isOver } = useDroppableColumn(status);
|
||||||
|
|
||||||
@@ -94,7 +116,13 @@ function Column({
|
|||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{tasks.map((task) => (
|
{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>
|
</SortableContext>
|
||||||
{tasks.length === 0 && (
|
{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 [tasks, setTasks] = useState<TaskRow[]>(initialTasks);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
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 (
|
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")}>
|
<Button onClick={() => handleAdd("todo")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Yeni görev
|
Yeni görev
|
||||||
@@ -258,6 +322,7 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(t) => setDeleting(t)}
|
onDelete={(t) => setDeleting(t)}
|
||||||
|
currentUserId={currentUserId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +334,7 @@ export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Prop
|
|||||||
onEdit={() => {}}
|
onEdit={() => {}}
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
isOverlay
|
isOverlay
|
||||||
|
currentUserId={currentUserId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export type TaskRow = {
|
|||||||
export type Customer = { id: string; name: string };
|
export type Customer = { id: string; name: string };
|
||||||
export type TeamMember = { 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 }[] = [
|
export const COLUMNS: { key: TaskStatus; title: string }[] = [
|
||||||
{ key: "backlog", title: "Beklemede" },
|
{ key: "backlog", title: "Beklemede" },
|
||||||
{ key: "todo", title: "Yapılacak" },
|
{ key: "todo", title: "Yapılacak" },
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import { listTasks } from "@/lib/appwrite/task-queries";
|
|||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { createAdminClient } from "@/lib/appwrite/server";
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
import { TasksBoard } from "./components/tasks-board";
|
import { TasksBoard } from "./components/tasks-board";
|
||||||
|
import type { TaskFilter } from "./components/types";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "İşletmem — Görevler",
|
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;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = await requireTenant();
|
ctx = await requireTenant();
|
||||||
@@ -19,11 +26,35 @@ export default async function TasksPage() {
|
|||||||
redirect("/onboarding");
|
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),
|
listTasks(ctx.tenantId),
|
||||||
listCustomers(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 }[] = [];
|
let teamMembers: { id: string; name: string }[] = [];
|
||||||
try {
|
try {
|
||||||
const { teams } = createAdminClient();
|
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>
|
<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>
|
<h1 className="text-2xl font-bold tracking-tight">Görevler</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,6 +97,9 @@ export default async function TasksPage() {
|
|||||||
}))}
|
}))}
|
||||||
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
|
currentUserId={ctx.user.id}
|
||||||
|
filter={filter}
|
||||||
|
filterCounts={filterCounts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ function monthLabel(d: Date): string {
|
|||||||
return MONTH_SHORT[d.getMonth()];
|
return MONTH_SHORT[d.getMonth()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardData(tenantId: string): Promise<DashboardData> {
|
export async function getDashboardData(
|
||||||
|
tenantId: string,
|
||||||
|
currentUserId?: string,
|
||||||
|
): Promise<DashboardData> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
const [customers, invoices, financeEntries, tasks, services] = await Promise.all([
|
const [customers, invoices, financeEntries, tasks, services] = await Promise.all([
|
||||||
@@ -129,10 +132,14 @@ export async function getDashboardData(tenantId: string): Promise<DashboardData>
|
|||||||
let openTasks = 0;
|
let openTasks = 0;
|
||||||
let urgentTasks = 0;
|
let urgentTasks = 0;
|
||||||
for (const t of taskList) {
|
for (const t of taskList) {
|
||||||
if ((t.status ?? "todo") !== "done") {
|
if ((t.status ?? "todo") === "done") continue;
|
||||||
openTasks += 1;
|
// Personal scope: own assigned + unassigned. Falls back to all if no userId.
|
||||||
if ((t.priority ?? "medium") === "urgent") urgentTasks += 1;
|
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;
|
const activeCustomers = customerList.filter((c) => (c.status ?? "active") === "active").length;
|
||||||
|
|||||||
Reference in New Issue
Block a user