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
+16 -2
View File
@@ -11,9 +11,9 @@ import { ActivitiesClient } from "@/components/activities/activities-client";
export default async function ActivitiesPage() { export default async function ActivitiesPage() {
const ctx = await requireTenant(); const ctx = await requireTenant();
const { tablesDB } = createAdminClient(); const { tablesDB, teams, users } = createAdminClient();
const [customers, properties, activitiesResult] = await Promise.all([ const [customers, properties, activitiesResult, membershipsResult] = await Promise.all([
listCustomers(ctx.tenantId), listCustomers(ctx.tenantId),
listProperties(ctx.tenantId), listProperties(ctx.tenantId),
tablesDB.listRows({ tablesDB.listRows({
@@ -25,10 +25,22 @@ export default async function ActivitiesPage() {
Query.limit(300), Query.limit(300),
], ],
}), }),
teams.listMemberships(ctx.tenantId),
]); ]);
const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[]; const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[];
const members = (
await Promise.all(
membershipsResult.memberships
.filter((m) => m.userId && m.confirm)
.map(async (m) => {
const u = await users.get(m.userId).catch(() => null);
return u ? { id: m.userId, name: u.name } : null;
}),
)
).filter((m): m is { id: string; name: string } => m !== null);
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
<ActivitiesClient <ActivitiesClient
@@ -36,6 +48,8 @@ export default async function ActivitiesPage() {
customers={customers} customers={customers}
properties={properties} properties={properties}
role={ctx.role} role={ctx.role}
members={members}
currentUserId={ctx.user.id}
/> />
</div> </div>
); );
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; 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 { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -19,19 +19,23 @@ import {
} from "@/lib/appwrite/activity-actions"; } from "@/lib/appwrite/activity-actions";
import { ActivityFormSheet } from "./activity-form-sheet"; import { ActivityFormSheet } from "./activity-form-sheet";
import { ActivityCalendar } from "./activity-calendar"; import { ActivityCalendar } from "./activity-calendar";
import { ActivityTeamView } from "./activity-team-view";
import { SendSummaryDialog } from "./send-summary-dialog"; import { SendSummaryDialog } from "./send-summary-dialog";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema"; import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema"; import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
import type { TenantRole } from "@/lib/appwrite/tenant-guard"; 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 { interface ActivitiesClientProps {
initialActivities: Activity[]; initialActivities: Activity[];
customers: Customer[]; customers: Customer[];
properties: Property[]; properties: Property[];
role: TenantRole; role: TenantRole;
members: TeamMember[];
currentUserId: string;
} }
export function ActivitiesClient({ export function ActivitiesClient({
@@ -39,6 +43,8 @@ export function ActivitiesClient({
customers, customers,
properties, properties,
role, role,
members,
currentUserId,
}: ActivitiesClientProps) { }: ActivitiesClientProps) {
const router = useRouter(); const router = useRouter();
const [activities, setActivities] = useState(initialActivities); const [activities, setActivities] = useState(initialActivities);
@@ -126,6 +132,20 @@ export function ActivitiesClient({
<CalendarDots className="size-3.5" /> <CalendarDots className="size-3.5" />
Takvim Takvim
</button> </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> </div>
<SendSummaryDialog role={role} /> <SendSummaryDialog role={role} />
<Button onClick={openCreate} size="sm" data-tour="activities-add"> <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 */} {/* List view */}
{viewMode === "list" && ( {viewMode === "list" && (
<div data-tour="activities-table" className="rounded-md border"> <div data-tour="activities-table" className="rounded-md border">
@@ -230,6 +262,9 @@ export function ActivitiesClient({
activity={editing} activity={editing}
customers={customers} customers={customers}
properties={properties} properties={properties}
members={members}
role={role}
currentUserId={currentUserId}
onSuccess={() => router.refresh()} onSuccess={() => router.refresh()}
/> />
<DeleteConfirmDialog <DeleteConfirmDialog
@@ -9,6 +9,9 @@ import { Textarea } from "@/components/ui/textarea";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet"; import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions"; import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema"; 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[]> }; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
const INITIAL: ActionState = { ok: false }; const INITIAL: ActionState = { ok: false };
@@ -19,10 +22,13 @@ interface ActivityFormSheetProps {
activity?: Activity | null; activity?: Activity | null;
customers: Customer[]; customers: Customer[];
properties: Property[]; properties: Property[];
members?: TeamMember[];
role?: TenantRole;
currentUserId?: string;
onSuccess?: () => void; 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 action = activity ? updateActivityAction.bind(null, activity.$id) : createActivityAction;
const [state, formAction, isPending] = useActionState(action, INITIAL); 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" <Input id="dueDate" name="dueDate" type="date"
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""} /> defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""} />
</div> </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>
);
}
+2
View File
@@ -37,6 +37,7 @@ export async function createActivityAction(
customerId: data.customerId, customerId: data.customerId,
propertyId: data.propertyId, propertyId: data.propertyId,
dueDate: data.dueDate, dueDate: data.dueDate,
assigneeId: data.assigneeId || ctx.user.id,
createdBy: ctx.user.id, createdBy: ctx.user.id,
}, },
[ [
@@ -77,6 +78,7 @@ export async function updateActivityAction(
customerId: data.customerId, customerId: data.customerId,
propertyId: data.propertyId, propertyId: data.propertyId,
dueDate: data.dueDate, dueDate: data.dueDate,
assigneeId: data.assigneeId || null,
}); });
} catch { } catch {
return { ok: false, error: "Aktivite güncellenemedi." }; return { ok: false, error: "Aktivite güncellenemedi." };
+6 -2
View File
@@ -53,7 +53,9 @@ export async function sendDailySummaryAction(
}); });
if (target === "me") { if (target === "me") {
const mine = todayActivities.filter((a) => a.createdBy === ctx.user.id); const mine = todayActivities.filter(
(a) => (a.assigneeId ?? a.createdBy) === ctx.user.id,
);
if (mine.length === 0) { if (mine.length === 0) {
return { ok: false, error: "Bugün için planlanmış aktiviteniz bulunmuyor." }; return { ok: false, error: "Bugün için planlanmış aktiviteniz bulunmuyor." };
} }
@@ -75,7 +77,9 @@ export async function sendDailySummaryAction(
for (const m of membershipsResult.memberships) { for (const m of membershipsResult.memberships) {
if (!m.userId || !m.confirm) continue; if (!m.userId || !m.confirm) continue;
const memberActivities = todayActivities.filter((a) => a.createdBy === m.userId); const memberActivities = todayActivities.filter(
(a) => (a.assigneeId ?? a.createdBy) === m.userId,
);
if (memberActivities.length === 0) continue; if (memberActivities.length === 0) continue;
const member = await users.get(m.userId).catch(() => null); const member = await users.get(m.userId).catch(() => null);
+1
View File
@@ -192,6 +192,7 @@ export interface Activity extends Row {
dueDate?: string; dueDate?: string;
completedAt?: string; completedAt?: string;
createdBy: string; createdBy: string;
assigneeId?: string;
} }
export interface TenantSettings extends Row { export interface TenantSettings extends Row {
+1
View File
@@ -7,6 +7,7 @@ export const activitySchema = z.object({
customerId: z.string().max(36).optional(), customerId: z.string().max(36).optional(),
propertyId: z.string().max(36).optional(), propertyId: z.string().max(36).optional(),
dueDate: z.string().optional(), dueDate: z.string().optional(),
assigneeId: z.string().max(36).optional(),
}); });
export type ActivityFormValues = z.infer<typeof activitySchema>; export type ActivityFormValues = z.infer<typeof activitySchema>;