feat: daily activity summary email
- activity-email-actions: sendDailySummaryAction — filters today's activities by dueDate, sends personalized email via Appwrite Messaging - 'me': current user's activities only - 'team' (owner/admin): each member gets their own activities separately - send-summary-dialog: dialog with me/team radio (owner/admin only sees team option), inline error + toast on success - activities-client: 'Günlük Özet' button in header, role prop added - activities page: passes ctx.role to client
This commit is contained in:
@@ -35,6 +35,7 @@ export default async function ActivitiesPage() {
|
|||||||
initialActivities={activities}
|
initialActivities={activities}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
properties={properties}
|
properties={properties}
|
||||||
|
role={ctx.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ 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 { 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";
|
||||||
|
|
||||||
type ViewMode = "list" | "calendar";
|
type ViewMode = "list" | "calendar";
|
||||||
|
|
||||||
@@ -29,12 +31,14 @@ interface ActivitiesClientProps {
|
|||||||
initialActivities: Activity[];
|
initialActivities: Activity[];
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
properties: Property[];
|
properties: Property[];
|
||||||
|
role: TenantRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivitiesClient({
|
export function ActivitiesClient({
|
||||||
initialActivities,
|
initialActivities,
|
||||||
customers,
|
customers,
|
||||||
properties,
|
properties,
|
||||||
|
role,
|
||||||
}: ActivitiesClientProps) {
|
}: ActivitiesClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activities, setActivities] = useState(initialActivities);
|
const [activities, setActivities] = useState(initialActivities);
|
||||||
@@ -123,6 +127,7 @@ export function ActivitiesClient({
|
|||||||
Takvim
|
Takvim
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<SendSummaryDialog role={role} />
|
||||||
<Button onClick={openCreate} size="sm" data-tour="activities-add">
|
<Button onClick={openCreate} size="sm" data-tour="activities-add">
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
Yeni Aktivite
|
Yeni Aktivite
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { Envelope, CircleNotch, Users, User } from "@/lib/icons";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { sendDailySummaryAction } from "@/lib/appwrite/activity-email-actions";
|
||||||
|
import type { SendSummaryState } from "@/lib/appwrite/activity-email-actions";
|
||||||
|
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
|
||||||
|
|
||||||
|
const initialState: SendSummaryState = { ok: false };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: TenantRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendSummaryDialog({ role }: Props) {
|
||||||
|
const canSendToTeam = role === "owner" || role === "admin";
|
||||||
|
const [state, formAction, isPending] = useActionState(sendDailySummaryAction, initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.ok && state.error) return; // shown inline
|
||||||
|
if (state.ok) {
|
||||||
|
const msg =
|
||||||
|
state.sent === 1
|
||||||
|
? "Günlük özet e-postanıza gönderildi."
|
||||||
|
: `Günlük özet ${state.sent} ekip üyesine gönderildi.`;
|
||||||
|
toast.success(msg);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Envelope className="mr-1.5 size-4" />
|
||||||
|
Günlük Özet
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Günlük aktivite özeti</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Bugün için planlanmış aktiviteler e-posta ile gönderilir.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form action={formAction} className="flex flex-col gap-5 pt-1">
|
||||||
|
{canSendToTeam && (
|
||||||
|
<RadioGroup name="target" defaultValue="me" className="gap-3">
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5 transition-colors">
|
||||||
|
<RadioGroupItem value="me" id="target-me" className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-sm">
|
||||||
|
<User className="size-4 text-muted-foreground" />
|
||||||
|
Sadece bana
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Yalnızca sizin bugünkü aktiviteleriniz gönderilir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5 transition-colors">
|
||||||
|
<RadioGroupItem value="team" id="target-team" className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-sm">
|
||||||
|
<Users className="size-4 text-muted-foreground" />
|
||||||
|
Tüm ekibe
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Her danışmana kendi aktiviteleri ayrı ayrı gönderilir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canSendToTeam && (
|
||||||
|
<input type="hidden" name="target" value="me" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-destructive text-sm text-center" role="alert">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
|
Gönderiliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Envelope className="size-4" />
|
||||||
|
Gönder
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ID } from "node-appwrite";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireTenant } from "./tenant-guard";
|
||||||
|
import { DATABASE_ID, TABLES, ACTIVITY_TYPE_LABELS, type Activity } from "./schema";
|
||||||
|
import type { AuthState } from "./auth-types";
|
||||||
|
|
||||||
|
export type SendTarget = "me" | "team";
|
||||||
|
|
||||||
|
export type SendSummaryState = AuthState & { sent?: number };
|
||||||
|
|
||||||
|
export async function sendDailySummaryAction(
|
||||||
|
_prev: SendSummaryState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SendSummaryState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Oturum geçersiz." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = (formData.get("target") ?? "me") as SendTarget;
|
||||||
|
|
||||||
|
if (target === "team" && !["owner", "admin"].includes(ctx.role)) {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tablesDB, teams, users, messaging } = createAdminClient();
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().split("T")[0]; // "YYYY-MM-DD"
|
||||||
|
|
||||||
|
const activitiesResult = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.activities,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.limit(500),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allActivities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[];
|
||||||
|
const todayActivities = allActivities.filter(
|
||||||
|
(a) => a.dueDate?.startsWith(todayStr),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateLabel = new Date().toLocaleDateString("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target === "me") {
|
||||||
|
const mine = todayActivities.filter((a) => a.createdBy === ctx.user.id);
|
||||||
|
if (mine.length === 0) {
|
||||||
|
return { ok: false, error: "Bugün için planlanmış aktiviteniz bulunmuyor." };
|
||||||
|
}
|
||||||
|
await messaging.createEmail(
|
||||||
|
ID.unique(),
|
||||||
|
`Bugünün Aktiviteleri — ${dateLabel}`,
|
||||||
|
buildEmailHtml(mine, ctx.user.name),
|
||||||
|
[],
|
||||||
|
[ctx.user.id],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return { ok: true, sent: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// send_to_team: her üyeye kendi aktiviteleri
|
||||||
|
const membershipsResult = await teams.listMemberships(ctx.tenantId);
|
||||||
|
let sentCount = 0;
|
||||||
|
|
||||||
|
for (const m of membershipsResult.memberships) {
|
||||||
|
if (!m.userId || !m.confirm) continue;
|
||||||
|
|
||||||
|
const memberActivities = todayActivities.filter((a) => a.createdBy === m.userId);
|
||||||
|
if (memberActivities.length === 0) continue;
|
||||||
|
|
||||||
|
const member = await users.get(m.userId).catch(() => null);
|
||||||
|
if (!member) continue;
|
||||||
|
|
||||||
|
await messaging.createEmail(
|
||||||
|
ID.unique(),
|
||||||
|
`Bugünün Aktiviteleri — ${dateLabel}`,
|
||||||
|
buildEmailHtml(memberActivities, member.name),
|
||||||
|
[],
|
||||||
|
[m.userId],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentCount === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Ekip üyelerinin bugün planlanmış aktivitesi bulunmuyor.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sent: sentCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailHtml(activities: Activity[], name: string): string {
|
||||||
|
const today = new Date().toLocaleDateString("tr-TR", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = activities
|
||||||
|
.map((a) => {
|
||||||
|
const done = a.completedAt
|
||||||
|
? `<span style="color:#16a34a;font-weight:600;">✓ Tamamlandı</span>`
|
||||||
|
: `<span style="color:#94a3b8;">○ Bekliyor</span>`;
|
||||||
|
const type = ACTIVITY_TYPE_LABELS[a.type] ?? a.type;
|
||||||
|
const desc = a.description
|
||||||
|
? `<div style="color:#64748b;font-size:13px;margin-top:2px;">${a.description}</div>`
|
||||||
|
: "";
|
||||||
|
return `
|
||||||
|
<tr style="border-bottom:1px solid #f1f5f9;">
|
||||||
|
<td style="padding:12px 16px;">
|
||||||
|
<div style="font-weight:500;color:#1e293b;">${a.title}</div>
|
||||||
|
${desc}
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;white-space:nowrap;">
|
||||||
|
<span style="background:#f1f5f9;color:#475569;font-size:12px;padding:2px 8px;border-radius:9999px;">${type}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;white-space:nowrap;font-size:13px;">${done}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><body style="margin:0;padding:0;background:#f8fafc;font-family:sans-serif;">
|
||||||
|
<div style="max-width:600px;margin:32px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);">
|
||||||
|
<div style="background:linear-gradient(135deg,#1e3a5f,#2563eb);padding:32px 32px 24px;">
|
||||||
|
<p style="color:#93c5fd;font-size:13px;margin:0 0 6px;">Emlak CRM · Günlük Özet</p>
|
||||||
|
<h1 style="color:#fff;font-size:22px;margin:0;">Merhaba, ${name}</h1>
|
||||||
|
<p style="color:#bfdbfe;font-size:14px;margin:8px 0 0;">${today}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 32px 8px;">
|
||||||
|
<p style="color:#475569;font-size:14px;margin:0 0 16px;">
|
||||||
|
Bugün için <strong>${activities.length} aktivite</strong> planlanmış:
|
||||||
|
</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<th style="padding:10px 16px;text-align:left;color:#64748b;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;">Aktivite</th>
|
||||||
|
<th style="padding:10px 16px;text-align:left;color:#64748b;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;">Tür</th>
|
||||||
|
<th style="padding:10px 16px;text-align:left;color:#64748b;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;">Durum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 32px;">
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin:0;">
|
||||||
|
Bu e-posta Emlak CRM tarafından otomatik olarak gönderilmiştir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user