feat: activity assignment + team view for owner/admin

db: activities.assigneeId column (string, optional)

activity-actions: assigneeId saved on create (default: self), cleared on update
validation: assigneeId added to activitySchema
schema: assigneeId field on Activity type

activity-form-sheet: Atanan Kişi dropdown for owner/admin with member list
activity-team-view: new component — activities grouped by assignee,
  completion/edit/delete actions, overdue indicator, member avatars
activities-client: Ekip tab (owner/admin only), members + currentUserId props
activities page: fetches team memberships + user details, passes to client
activity-email-actions: filter by assigneeId ?? createdBy for both me/team modes
This commit is contained in:
egecankomur
2026-05-12 17:40:21 +03:00
parent 5ac6a1f8b0
commit 7c23a2b4ae
8 changed files with 319 additions and 7 deletions
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, List, CalendarDots } from '@/lib/icons';
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, List, CalendarDots, Users } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -19,19 +19,23 @@ import {
} from "@/lib/appwrite/activity-actions";
import { ActivityFormSheet } from "./activity-form-sheet";
import { ActivityCalendar } from "./activity-calendar";
import { ActivityTeamView } from "./activity-team-view";
import { SendSummaryDialog } from "./send-summary-dialog";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
import type { TeamMember } from "./activity-form-sheet";
type ViewMode = "list" | "calendar";
type ViewMode = "list" | "calendar" | "team";
interface ActivitiesClientProps {
initialActivities: Activity[];
customers: Customer[];
properties: Property[];
role: TenantRole;
members: TeamMember[];
currentUserId: string;
}
export function ActivitiesClient({
@@ -39,6 +43,8 @@ export function ActivitiesClient({
customers,
properties,
role,
members,
currentUserId,
}: ActivitiesClientProps) {
const router = useRouter();
const [activities, setActivities] = useState(initialActivities);
@@ -126,6 +132,20 @@ export function ActivitiesClient({
<CalendarDots className="size-3.5" />
Takvim
</button>
{(role === "owner" || role === "admin") && (
<button
type="button"
onClick={() => setViewMode("team")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "team"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<Users className="size-3.5" />
Ekip
</button>
)}
</div>
<SendSummaryDialog role={role} />
<Button onClick={openCreate} size="sm" data-tour="activities-add">
@@ -146,6 +166,18 @@ export function ActivitiesClient({
/>
)}
{/* Team view — owner/admin only */}
{viewMode === "team" && (
<ActivityTeamView
activities={activities}
members={members}
currentUserId={currentUserId}
onEdit={openEdit}
onComplete={handleComplete}
onDelete={setDeleteTarget}
/>
)}
{/* List view */}
{viewMode === "list" && (
<div data-tour="activities-table" className="rounded-md border">
@@ -230,6 +262,9 @@ export function ActivitiesClient({
activity={editing}
customers={customers}
properties={properties}
members={members}
role={role}
currentUserId={currentUserId}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
@@ -9,6 +9,9 @@ import { Textarea } from "@/components/ui/textarea";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
export type TeamMember = { id: string; name: string };
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
const INITIAL: ActionState = { ok: false };
@@ -19,10 +22,13 @@ interface ActivityFormSheetProps {
activity?: Activity | null;
customers: Customer[];
properties: Property[];
members?: TeamMember[];
role?: TenantRole;
currentUserId?: string;
onSuccess?: () => void;
}
export function ActivityFormSheet({ open, onOpenChange, activity, customers, properties, onSuccess }: ActivityFormSheetProps) {
export function ActivityFormSheet({ open, onOpenChange, activity, customers, properties, members, role, currentUserId, onSuccess }: ActivityFormSheetProps) {
const action = activity ? updateActivityAction.bind(null, activity.$id) : createActivityAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
@@ -66,6 +72,22 @@ export function ActivityFormSheet({ open, onOpenChange, activity, customers, pro
<Input id="dueDate" name="dueDate" type="date"
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""} />
</div>
{(role === "owner" || role === "admin") && members && members.length > 0 && (
<div className="grid gap-1.5">
<Label>Atanan Kişi</Label>
<select
name="assigneeId"
defaultValue={activity?.assigneeId ?? currentUserId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.id === currentUserId ? `${m.name} (ben)` : m.name}
</option>
))}
</select>
</div>
)}
</>
),
},
@@ -0,0 +1,233 @@
"use client";
import { CheckCircle, DotsThree, PencilSimple, Trash, User } from "@/lib/icons";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
import type { TeamMember } from "./activity-form-sheet";
interface Props {
activities: Activity[];
members: TeamMember[];
currentUserId: string;
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
onDelete: (a: Activity) => void;
}
export function ActivityTeamView({
activities,
members,
currentUserId,
onEdit,
onComplete,
onDelete,
}: Props) {
const memberIds = new Set(members.map((m) => m.id));
// Group by effective assignee (assigneeId ?? createdBy)
const groups = members
.map((member) => ({
member,
activities: activities.filter((a) => {
const effective = a.assigneeId || a.createdBy;
return effective === member.id;
}),
}))
.filter((g) => g.activities.length > 0);
// Activities not belonging to any known member
const orphaned = activities.filter((a) => {
const effective = a.assigneeId || a.createdBy;
return !memberIds.has(effective);
});
if (groups.length === 0 && orphaned.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<User className="size-10 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground text-sm">Henüz aktivite bulunmuyor.</p>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{groups.map(({ member, activities: memberActivities }) => (
<MemberSection
key={member.id}
member={member}
isCurrentUser={member.id === currentUserId}
activities={memberActivities}
onEdit={onEdit}
onComplete={onComplete}
onDelete={onDelete}
/>
))}
{orphaned.length > 0 && (
<MemberSection
member={{ id: "", name: "Atanmamış" }}
isCurrentUser={false}
activities={orphaned}
onEdit={onEdit}
onComplete={onComplete}
onDelete={onDelete}
/>
)}
</div>
);
}
function MemberSection({
member,
isCurrentUser,
activities,
onEdit,
onComplete,
onDelete,
}: {
member: TeamMember;
isCurrentUser: boolean;
activities: Activity[];
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
onDelete: (a: Activity) => void;
}) {
const pending = activities.filter((a) => !a.completedAt).length;
return (
<div className="rounded-xl border bg-card">
{/* Section header */}
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/40 rounded-t-xl">
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-semibold shrink-0">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm">
{member.name}
{isCurrentUser && (
<span className="text-muted-foreground font-normal ml-1">(ben)</span>
)}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{pending > 0 && (
<Badge variant="secondary" className="text-xs">
{pending} bekliyor
</Badge>
)}
<Badge variant="outline" className="text-xs">
{activities.length} toplam
</Badge>
</div>
</div>
{/* Activities */}
<div className="divide-y">
{activities.map((a) => (
<ActivityRow
key={a.$id}
activity={a}
onEdit={onEdit}
onComplete={onComplete}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
function ActivityRow({
activity: a,
onEdit,
onComplete,
onDelete,
}: {
activity: Activity;
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
onDelete: (a: Activity) => void;
}) {
const isCompleted = !!a.completedAt;
const isOverdue =
!isCompleted && a.dueDate && new Date(a.dueDate) < new Date(new Date().setHours(0, 0, 0, 0));
return (
<div className="flex items-start gap-3 px-4 py-3 hover:bg-muted/30 transition-colors group">
{/* Completion indicator */}
<button
type="button"
onClick={() => !isCompleted && onComplete(a)}
disabled={isCompleted}
title={isCompleted ? "Tamamlandı" : "Tamamla"}
className={`mt-0.5 shrink-0 rounded-full transition-colors ${
isCompleted
? "text-green-500 cursor-default"
: "text-muted-foreground/40 hover:text-green-500 cursor-pointer"
}`}
>
<CheckCircle className="size-5" weight={isCompleted ? "fill" : "regular"} />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium leading-snug truncate ${isCompleted ? "line-through text-muted-foreground" : ""}`}>
{a.title}
</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant="secondary" className="text-xs py-0">
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
</Badge>
{a.dueDate && (
<span className={`text-xs ${isOverdue ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{new Date(a.dueDate).toLocaleDateString("tr-TR", { day: "numeric", month: "short" })}
{isOverdue && " · Gecikmiş"}
</span>
)}
{a.description && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{a.description}
</span>
)}
</div>
</div>
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isCompleted && (
<DropdownMenuItem onClick={() => onComplete(a)}>
<CheckCircle className="size-4 mr-2" />
Tamamla
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onEdit(a)}>
<PencilSimple className="size-4 mr-2" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(a)}
className="text-destructive focus:text-destructive"
>
<Trash className="size-4 mr-2" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}