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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user