feat(calendar): implement calendar event management with create, update, and delete functionalities

- Added actions for creating, updating, and deleting calendar events in `actions.ts`.
- Introduced `CalendarClient` component to handle event display and interaction in `calendar-client.tsx`.
- Refactored `CalendarPage` to fetch events, clients, projects, and tasks from Supabase and pass them to `CalendarClient`.
- Enhanced form handling for event creation and editing with proper validation and error handling.
This commit is contained in:
Poyraz Avsever
2026-06-04 19:53:44 +03:00
parent f17be7cfed
commit 7ee3104158
3 changed files with 661 additions and 331 deletions
@@ -0,0 +1,456 @@
"use client";
import {
createCalendarEventRecord,
deleteCalendarEventRecord,
updateCalendarEventRecord,
} from "@/app/(dashboard)/calendar/actions";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "poyraz-ui/molecules";
import { CalendarDays, Clock, Pencil, Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
export type CalendarRelationOption = {
id: string;
name: string;
};
export type CalendarTaskOption = {
id: string;
title: string;
};
export type CalendarEventItem = {
id: string;
title: string;
description: string | null;
type: "meeting" | "focus" | "deadline" | "personal" | "finance";
starts_at: string;
ends_at: string | null;
client_id: string | null;
project_id: string | null;
task_id: string | null;
clientName: string | null;
projectName: string | null;
taskTitle: string | null;
};
const typeLabels = {
meeting: "Toplantı",
focus: "Odak",
deadline: "Deadline",
personal: "Kişisel",
finance: "Finans",
};
const typeClasses = {
meeting: "border-blue-200 bg-blue-50 text-blue-700",
focus: "border-emerald-200 bg-emerald-50 text-emerald-700",
deadline: "border-rose-200 bg-rose-50 text-rose-700",
personal: "border-amber-200 bg-amber-50 text-amber-700",
finance: "border-primary/20 bg-primary/10 text-primary",
};
type CalendarClientProps = {
events: CalendarEventItem[];
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
};
export function CalendarClient({ events, clients, projects, tasks }: CalendarClientProps) {
const [monthDate, setMonthDate] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => toDateKey(new Date()));
const days = useMemo(() => buildMonthDays(monthDate), [monthDate]);
const eventsByDay = useMemo(() => groupEventsByDay(events), [events]);
const selectedEvents = eventsByDay.get(selectedDate) || [];
const upcomingEvents = events
.filter((event) => new Date(event.starts_at) >= startOfToday())
.slice(0, 6);
function shiftMonth(amount: number) {
setMonthDate((current) => new Date(current.getFullYear(), current.getMonth() + amount, 1));
}
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CalendarDays className="h-4 w-4" />
Planlama
</div>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">Takvim</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Toplantı, odak bloğu, deadline, kişisel ve finans etkinliklerini yönet.
</p>
</div>
</div>
<CalendarEventDialog
mode="create"
defaultDate={selectedDate}
clients={clients}
projects={projects}
tasks={tasks}
/>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<Card>
<CardContent className="p-4">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">
{formatMonth(monthDate)}
</h2>
<p className="text-sm text-muted-foreground">{events.length} etkinlik</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => shiftMonth(-1)}>
Önceki
</Button>
<Button type="button" variant="outline" onClick={() => setMonthDate(new Date())}>
Bugün
</Button>
<Button type="button" variant="outline" onClick={() => shiftMonth(1)}>
Sonraki
</Button>
</div>
</div>
<div className="grid grid-cols-7 border border-border text-center text-xs font-medium uppercase text-muted-foreground">
{["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"].map((day) => (
<div key={day} className="border-r border-border px-2 py-2 last:border-r-0">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 border-x border-border">
{days.map((day) => {
const dayEvents = eventsByDay.get(day.key) || [];
const isSelected = selectedDate === day.key;
return (
<button
key={day.key}
type="button"
onClick={() => setSelectedDate(day.key)}
className={`min-h-28 border-b border-r border-border p-2 text-left transition-colors last:border-r-0 hover:bg-muted/40 ${
!day.inMonth ? "bg-muted/20 text-muted-foreground" : "bg-background"
} ${isSelected ? "ring-2 ring-inset ring-primary" : ""}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">{day.date.getDate()}</span>
{toDateKey(day.date) === toDateKey(new Date()) ? (
<span className="h-2 w-2 rounded-full bg-primary" />
) : null}
</div>
<div className="tiny-scrollbar max-h-20 space-y-1 overflow-y-auto">
{dayEvents.slice(0, 3).map((event) => (
<div key={event.id} className="truncate rounded-sm bg-primary/10 px-1.5 py-1 text-xs text-primary">
{event.title}
</div>
))}
{dayEvents.length > 3 ? (
<div className="text-xs text-muted-foreground">+{dayEvents.length - 3}</div>
) : null}
</div>
</button>
);
})}
</div>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardContent className="space-y-3 p-4">
<div>
<h2 className="text-base font-semibold text-foreground">
{formatDateLabel(selectedDate)}
</h2>
<p className="text-sm text-muted-foreground">
{selectedEvents.length} etkinlik
</p>
</div>
<CalendarEventDialog
mode="create"
defaultDate={selectedDate}
clients={clients}
projects={projects}
tasks={tasks}
/>
<EventList events={selectedEvents} clients={clients} projects={projects} tasks={tasks} />
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 p-4">
<h2 className="text-base font-semibold text-foreground">Yaklaşan etkinlikler</h2>
<EventList events={upcomingEvents} clients={clients} projects={projects} tasks={tasks} compact />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function EventList({
events,
clients,
projects,
tasks,
compact = false,
}: {
events: CalendarEventItem[];
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
compact?: boolean;
}) {
if (events.length === 0) {
return <p className="text-sm text-muted-foreground">Etkinlik yok.</p>;
}
return (
<div className="space-y-2">
{events.map((event) => (
<div key={event.id} className="rounded-sm border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{event.title}</div>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{formatTimeRange(event)}
</div>
{!compact ? (
<div className="mt-2 text-sm text-muted-foreground">
{event.projectName || event.clientName || event.taskTitle || event.description || "Bağlantı yok"}
</div>
) : null}
</div>
<Badge className={typeClasses[event.type]}>{typeLabels[event.type]}</Badge>
</div>
{!compact ? (
<div className="mt-3 flex gap-2">
<CalendarEventDialog mode="edit" event={event} clients={clients} projects={projects} tasks={tasks} />
<form action={deleteCalendarEventRecord}>
<input type="hidden" name="id" value={event.id} />
<Button type="submit" variant="outline" className="h-9 gap-2 text-rose-600">
<Trash2 className="h-4 w-4" />
Sil
</Button>
</form>
</div>
) : null}
</div>
))}
</div>
);
}
function CalendarEventDialog({
mode,
event,
defaultDate,
clients,
projects,
tasks,
}: {
mode: "create" | "edit";
event?: CalendarEventItem;
defaultDate?: string;
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
}) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const action = mode === "create" ? createCalendarEventRecord : updateCalendarEventRecord;
async function handleSubmit(formData: FormData) {
setIsSubmitting(true);
try {
await action(formData);
setOpen(false);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="h-9 gap-2" variant={mode === "create" ? "default" : "outline"}>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Etkinlik ekle" : "Düzenle"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[min(680px,calc(100dvh-6rem))] overflow-hidden sm:max-w-xl">
<form action={handleSubmit} className="flex max-h-[min(640px,calc(100dvh-9rem))] flex-col">
{event ? <input type="hidden" name="id" value={event.id} /> : null}
<DialogHeader className="shrink-0 pb-5">
<DialogTitle>{mode === "create" ? "Yeni etkinlik" : "Etkinliği düzenle"}</DialogTitle>
<DialogDescription>Takvim etkinliğini proje, görev veya müşteriyle ilişkilendir.</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto pr-2">
<EventFormFields event={event} defaultDate={defaultDate} clients={clients} projects={projects} tasks={tasks} />
</div>
<DialogFooter className="shrink-0 border-t border-border pt-5">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Kaydediliyor" : mode === "create" ? "Etkinliği ekle" : "Değişiklikleri kaydet"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function EventFormFields({
event,
defaultDate,
clients,
projects,
tasks,
}: {
event?: CalendarEventItem;
defaultDate?: string;
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
}) {
const startsAt = event?.starts_at ? toDateTimeLocal(event.starts_at) : `${defaultDate || toDateKey(new Date())}T09:00`;
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Başlık</Label>
<Input name="title" defaultValue={event?.title || ""} required placeholder="Örn. Müşteri toplantısı" />
</div>
<div className="grid gap-2">
<Label>Açıklama</Label>
<Textarea name="description" defaultValue={event?.description || ""} rows={3} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<SelectField name="type" label="Tür" defaultValue={event?.type || "focus"}>
<SelectItem value="meeting">Toplantı</SelectItem>
<SelectItem value="focus">Odak</SelectItem>
<SelectItem value="deadline">Deadline</SelectItem>
<SelectItem value="personal">Kişisel</SelectItem>
<SelectItem value="finance">Finans</SelectItem>
</SelectField>
<SelectField name="client_id" label="Müşteri" defaultValue={event?.client_id || "__none"}>
<SelectItem value="__none">Müşteri yok</SelectItem>
{clients.map((client) => <SelectItem key={client.id} value={client.id}>{client.name}</SelectItem>)}
</SelectField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SelectField name="project_id" label="Proje" defaultValue={event?.project_id || "__none"}>
<SelectItem value="__none">Proje yok</SelectItem>
{projects.map((project) => <SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>)}
</SelectField>
<SelectField name="task_id" label="Görev" defaultValue={event?.task_id || "__none"}>
<SelectItem value="__none">Görev yok</SelectItem>
{tasks.map((task) => <SelectItem key={task.id} value={task.id}>{task.title}</SelectItem>)}
</SelectField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Başlangıç</Label>
<Input name="starts_at" type="datetime-local" defaultValue={startsAt} required />
</div>
<div className="grid gap-2">
<Label>Bitiş</Label>
<Input name="ends_at" type="datetime-local" defaultValue={event?.ends_at ? toDateTimeLocal(event.ends_at) : ""} />
</div>
</div>
</div>
);
}
function SelectField({ name, label, defaultValue, children }: { name: string; label: string; defaultValue: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<Select name={name} defaultValue={defaultValue}>
<SelectTrigger><SelectValue placeholder={`${label} seç`} /></SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
</div>
);
}
function buildMonthDays(monthDate: Date) {
const year = monthDate.getFullYear();
const month = monthDate.getMonth();
const firstDay = new Date(year, month, 1);
const offset = (firstDay.getDay() + 6) % 7;
const start = new Date(year, month, 1 - offset);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return { date, key: toDateKey(date), inMonth: date.getMonth() === month };
});
}
function groupEventsByDay(events: CalendarEventItem[]) {
const map = new Map<string, CalendarEventItem[]>();
for (const event of events) {
const key = toDateKey(new Date(event.starts_at));
map.set(key, [...(map.get(key) || []), event]);
}
return map;
}
function toDateKey(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function startOfToday() {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
}
function formatMonth(date: Date) {
return new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format(date);
}
function formatDateLabel(dateKey: string) {
return new Intl.DateTimeFormat("tr-TR", { day: "2-digit", month: "long", year: "numeric" }).format(new Date(`${dateKey}T00:00:00`));
}
function formatTimeRange(event: CalendarEventItem) {
const start = new Intl.DateTimeFormat("tr-TR", { hour: "2-digit", minute: "2-digit" }).format(new Date(event.starts_at));
const end = event.ends_at ? new Intl.DateTimeFormat("tr-TR", { hour: "2-digit", minute: "2-digit" }).format(new Date(event.ends_at)) : null;
return end ? `${start} - ${end}` : start;
}
function toDateTimeLocal(value: string) {
const date = new Date(value);
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return offsetDate.toISOString().slice(0, 16);
}