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:
@@ -0,0 +1,106 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const EVENT_TYPES = ["meeting", "focus", "deadline", "personal", "finance"] as const;
|
||||||
|
|
||||||
|
function cleanText(value: FormDataEntryValue | null) {
|
||||||
|
const text = typeof value === "string" ? value.trim() : "";
|
||||||
|
return text.length > 0 && text !== "__none" ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readType(value: FormDataEntryValue | null) {
|
||||||
|
const type = typeof value === "string" ? value : "focus";
|
||||||
|
return EVENT_TYPES.includes(type as (typeof EVENT_TYPES)[number]) ? type : "focus";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUserId() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
throw new Error("Takvim işlemi için giriş yapmış kullanıcı bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supabase, userId: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayload(formData: FormData) {
|
||||||
|
return {
|
||||||
|
title: cleanText(formData.get("title")),
|
||||||
|
description: cleanText(formData.get("description")),
|
||||||
|
type: readType(formData.get("type")),
|
||||||
|
starts_at: cleanText(formData.get("starts_at")),
|
||||||
|
ends_at: cleanText(formData.get("ends_at")),
|
||||||
|
client_id: cleanText(formData.get("client_id")),
|
||||||
|
project_id: cleanText(formData.get("project_id")),
|
||||||
|
task_id: cleanText(formData.get("task_id")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCalendarEventRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const payload = readPayload(formData);
|
||||||
|
|
||||||
|
if (!payload.title || !payload.starts_at) {
|
||||||
|
throw new Error("Etkinlik başlığı ve başlangıç zamanı zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from("calendar_events").insert({
|
||||||
|
user_id: userId,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Etkinlik eklenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCalendarEventRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
const payload = readPayload(formData);
|
||||||
|
|
||||||
|
if (!id || !payload.title || !payload.starts_at) {
|
||||||
|
throw new Error("Etkinlik güncellemek için başlık, başlangıç ve kayıt kimliği zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("calendar_events")
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Etkinlik güncellenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCalendarEventRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Silinecek etkinlik bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("calendar_events")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Etkinlik silinemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,339 +1,107 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Plus, Search, Calendar as CalendarIcon,
|
CalendarClient,
|
||||||
Clock, Filter, MoreHorizontal, Brain, X, Check, ArrowRight
|
type CalendarEventItem,
|
||||||
} from "lucide-react";
|
type CalendarRelationOption,
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
type CalendarTaskOption,
|
||||||
|
} from "@/app/(dashboard)/calendar/calendar-client";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
// Mock Data
|
type CalendarEventRow = {
|
||||||
const categories = [
|
id: string;
|
||||||
{ name: "Deep Work", color: "bg-[#6C5BB0]" },
|
title: string;
|
||||||
{ name: "Meetings", color: "bg-orange-500" },
|
description: string | null;
|
||||||
{ name: "Project Deadlines", color: "bg-blue-500" },
|
type: CalendarEventItem["type"];
|
||||||
{ name: "Health & Habits", color: "bg-emerald-500" },
|
starts_at: string;
|
||||||
];
|
ends_at: string | null;
|
||||||
|
client_id: string | null;
|
||||||
const mockEvents = [
|
project_id: string | null;
|
||||||
{ day: 2, title: "Q3 Planning", type: "Meetings", time: "10:00 AM", colorClass: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
task_id: string | null;
|
||||||
{ day: 5, title: "Deep Work Session", type: "Deep Work", time: "09:00 AM", colorClass: "bg-[#6C5BB0]/10 text-[#a798e8] border-[#6C5BB0]/20" },
|
clients: { name: string } | { name: string }[] | null;
|
||||||
{ day: 5, title: "UI Review", type: "Project Deadlines", time: "02:00 PM", colorClass: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
projects: { name: string } | { name: string }[] | null;
|
||||||
{ day: 12, title: "Frontend Deploy", type: "Project Deadlines", time: "11:00 AM", colorClass: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
tasks: { title: string } | { title: string }[] | null;
|
||||||
{ day: 15, title: "1:1 with Alex", type: "Meetings", time: "01:30 PM", colorClass: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
|
||||||
{ day: 15, title: "Gym", type: "Health & Habits", time: "06:00 PM", colorClass: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
|
||||||
{ day: 18, title: "Focus Block", type: "Deep Work", time: "08:00 AM", colorClass: "bg-[#6C5BB0]/10 text-[#a798e8] border-[#6C5BB0]/20" },
|
|
||||||
{ day: 22, title: "Reading", type: "Health & Habits", time: "09:00 PM", colorClass: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
|
||||||
{ day: 25, title: "Client Demo", type: "Project Deadlines", time: "03:00 PM", colorClass: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
|
||||||
{ day: 28, title: "Retrospective", type: "Meetings", time: "04:00 PM", colorClass: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
||||||
|
|
||||||
const generateCalendarDays = () => {
|
|
||||||
const days = [];
|
|
||||||
days.push({ empty: true, key: "empty-0" });
|
|
||||||
for (let i = 1; i <= 30; i++) {
|
|
||||||
const events = mockEvents.filter((e) => e.day === i);
|
|
||||||
days.push({ empty: false, day: i, events, isToday: i === 15 });
|
|
||||||
}
|
|
||||||
const remaining = 35 - days.length;
|
|
||||||
for (let i = 1; i <= remaining; i++) {
|
|
||||||
days.push({ empty: true, key: `empty-end-${i}` });
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default async function CalendarPage() {
|
||||||
const [selectedDay, setSelectedDay] = useState<any>(null);
|
const supabase = await createClient();
|
||||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
const {
|
||||||
const calendarDays = generateCalendarDays();
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
const handleOptimize = () => {
|
if (!user) {
|
||||||
setIsOptimizing(true);
|
return null;
|
||||||
setTimeout(() => setIsOptimizing(false), 2500);
|
}
|
||||||
};
|
|
||||||
|
const [{ data: eventRows }, { data: clientRows }, { data: projectRows }, { data: taskRows }] =
|
||||||
|
await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from("calendar_events")
|
||||||
|
.select("id, title, description, type, starts_at, ends_at, client_id, project_id, task_id, clients(name), projects(name), tasks(title)")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.order("starts_at", { ascending: true }),
|
||||||
|
supabase
|
||||||
|
.from("clients")
|
||||||
|
.select("id, name")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.neq("status", "archived")
|
||||||
|
.order("name", { ascending: true }),
|
||||||
|
supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("id, name")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.neq("status", "cancelled")
|
||||||
|
.order("name", { ascending: true }),
|
||||||
|
supabase
|
||||||
|
.from("tasks")
|
||||||
|
.select("id, title")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.neq("status", "done")
|
||||||
|
.order("created_at", { ascending: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events: CalendarEventItem[] = ((eventRows || []) as unknown as CalendarEventRow[]).map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
type: normalizeType(event.type),
|
||||||
|
starts_at: event.starts_at,
|
||||||
|
ends_at: event.ends_at,
|
||||||
|
client_id: event.client_id,
|
||||||
|
project_id: event.project_id,
|
||||||
|
task_id: event.task_id,
|
||||||
|
clientName: getRelationName(event.clients),
|
||||||
|
projectName: getRelationName(event.projects),
|
||||||
|
taskTitle: getRelationTitle(event.tasks),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans relative">
|
<CalendarClient
|
||||||
|
events={events}
|
||||||
{/* Optimization Overlay */}
|
clients={(clientRows || []) as CalendarRelationOption[]}
|
||||||
<AnimatePresence>
|
projects={(projectRows || []) as CalendarRelationOption[]}
|
||||||
{isOptimizing && (
|
tasks={(taskRows || []) as CalendarTaskOption[]}
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="absolute inset-0 z-50 bg-[#0A0710]/80 backdrop-blur-md flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
rotate: [0, 10, -10, 0]
|
|
||||||
}}
|
|
||||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
|
||||||
className="mb-6"
|
|
||||||
>
|
|
||||||
<Brain className="h-16 w-16 text-primary" />
|
|
||||||
</motion.div>
|
|
||||||
<h2 className="text-xl font-bold mb-2">AI Schedule Optimization</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">Analyzing energy trends and focus blocks...</p>
|
|
||||||
<div className="w-48 h-1 bg-white/10 rounded-full mt-6 overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: "100%" }}
|
|
||||||
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
|
|
||||||
className="w-full h-full bg-primary"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Top Header */}
|
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mb-6 mt-4">
|
|
||||||
<h1 className="text-lg font-medium text-muted-foreground">
|
|
||||||
<span className="text-foreground">Calendar</span> / Monthly View
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search events..."
|
|
||||||
className="bg-transparent border-none outline-none text-xs w-48 placeholder:text-muted-foreground/50 text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedDay({ day: 15, events: mockEvents.filter(e => e.day === 15) })}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-4 py-1.5 rounded-sm text-xs font-semibold flex items-center gap-2 transition-all active:scale-95 shadow-lg shadow-primary/20"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
NEW EVENT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
|
||||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-6 min-h-0">
|
|
||||||
|
|
||||||
{/* Left Sidebar Panel */}
|
|
||||||
<div className="hidden lg:flex flex-col gap-6">
|
|
||||||
|
|
||||||
{/* Mini Calendar Mock */}
|
|
||||||
<div className="rounded-sm border border-white/5 bg-[#0A0710] p-5">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<span className="text-sm font-semibold">May 2026</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className="p-1 hover:bg-white/5 rounded-sm text-muted-foreground transition-colors"><ChevronLeft className="h-4 w-4" /></button>
|
|
||||||
<button className="p-1 hover:bg-white/5 rounded-sm text-muted-foreground transition-colors"><ChevronRight className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-[10px] text-muted-foreground font-semibold mb-2">
|
|
||||||
<div>M</div><div>T</div><div>W</div><div>T</div><div>F</div><div>S</div><div>S</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs">
|
|
||||||
<div className="text-muted-foreground/30 py-1">27</div>
|
|
||||||
{[...Array(30)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`py-1 rounded-sm cursor-pointer hover:bg-white/5 transition-colors ${i + 1 === 15 ? 'bg-primary/20 text-primary font-bold' : ''}`}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendars / Categories */}
|
|
||||||
<div className="rounded-sm border border-white/5 bg-[#0A0710] p-5 flex-1">
|
|
||||||
<div className="flex justify-between items-center mb-5">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">My Calendars</h3>
|
|
||||||
<button className="text-muted-foreground hover:text-foreground"><Filter className="h-3.5 w-3.5" /></button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{categories.map((cat, idx) => (
|
|
||||||
<label key={idx} className="flex items-center gap-3 cursor-pointer group">
|
|
||||||
<div className={`w-3 h-3 rounded-sm ${cat.color} ring-2 ring-transparent group-hover:ring-white/10 transition-all shadow-[0_0_8px_rgba(108,91,176,0.3)]`}></div>
|
|
||||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">{cat.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Optimizer Card */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ y: -4 }}
|
|
||||||
className="rounded-sm border border-primary/20 bg-primary/5 p-5 flex flex-col gap-3 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute -right-4 -bottom-4 opacity-5 rotate-12">
|
|
||||||
<Brain className="h-24 w-24 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Brain className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-primary">AI Optimizer</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
||||||
Detected focus peak at 10 AM on Thursdays. Suggest moving **UI Review** to maximize alignment.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleOptimize}
|
|
||||||
className="bg-primary/10 text-primary hover:bg-primary/20 border border-primary/20 px-3 py-1.5 rounded-sm text-[10px] font-bold uppercase tracking-widest transition-all mt-2 text-center flex justify-center w-full"
|
|
||||||
>
|
|
||||||
Run AI Optimization
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Calendar View */}
|
|
||||||
<div className="lg:col-span-3 flex flex-col rounded-sm border border-white/5 bg-[#0A0710] overflow-hidden">
|
|
||||||
|
|
||||||
{/* Calendar Toolbar */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-[#0F0B15]/30">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h2 className="text-xl font-semibold">May 2026</h2>
|
|
||||||
<div className="flex items-center bg-[#150F1D] border border-white/5 rounded-sm p-0.5">
|
|
||||||
<button className="p-1 hover:bg-white/5 rounded-sm transition-colors text-muted-foreground"><ChevronLeft className="h-4 w-4" /></button>
|
|
||||||
<button className="px-3 py-1 text-xs font-medium hover:bg-white/5 rounded-sm transition-colors text-foreground">Today</button>
|
|
||||||
<button className="p-1 hover:bg-white/5 rounded-sm transition-colors text-muted-foreground"><ChevronRight className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 bg-[#150F1D] border border-white/5 rounded-sm p-0.5">
|
|
||||||
<button className="px-3 py-1.5 text-xs font-medium rounded-sm text-muted-foreground hover:text-foreground transition-colors">Day</button>
|
|
||||||
<button className="px-3 py-1.5 text-xs font-medium rounded-sm text-muted-foreground hover:text-foreground transition-colors">Week</button>
|
|
||||||
<button className="px-3 py-1.5 text-xs font-medium rounded-sm bg-[#2B2538] text-foreground shadow-sm">Month</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Header */}
|
|
||||||
<div className="grid grid-cols-7 border-b border-white/5 bg-[#0F0B15]/50">
|
|
||||||
{daysOfWeek.map((day) => (
|
|
||||||
<div key={day} className="py-3 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
<div className="flex-1 grid grid-cols-7 grid-rows-5 overflow-hidden bg-white/[0.02] gap-[1px]">
|
|
||||||
{calendarDays.map((cell, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={cell.key || cell.day}
|
|
||||||
whileHover={{ backgroundColor: "rgba(15, 11, 21, 0.8)" }}
|
|
||||||
onClick={() => !cell.empty && setSelectedDay(cell)}
|
|
||||||
className={`bg-[#0A0710] p-2 flex flex-col gap-1 transition-all cursor-pointer relative group ${cell.empty ? 'opacity-30 pointer-events-none' : ''}`}
|
|
||||||
>
|
|
||||||
{!cell.empty && (
|
|
||||||
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-sm transition-colors ${cell.isToday ? 'bg-primary text-primary-foreground shadow-[0_0_12px_rgba(108,91,176,0.5)]' : 'text-muted-foreground group-hover:text-foreground'}`}>
|
|
||||||
{cell.day}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto tiny-scrollbar flex flex-col gap-1 pr-1">
|
|
||||||
{cell.events?.map((event, eIdx) => (
|
|
||||||
<motion.div
|
|
||||||
key={eIdx}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: idx * 0.01 + eIdx * 0.05 }}
|
|
||||||
className={`text-[9px] px-1.5 py-1 rounded-sm border truncate font-bold tracking-tight transition-all hover:scale-[1.02] active:scale-95 ${event.colorClass}`}
|
|
||||||
>
|
|
||||||
{event.title}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Entry Sheet */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedDay && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={() => setSelectedDay(null)}
|
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "100%" }}
|
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
|
||||||
className="fixed top-0 right-0 h-full w-full max-w-md bg-[#0A0710] border-l border-white/5 z-[101] shadow-2xl flex flex-col"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-[#0F0B15]/50">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold">May {selectedDay.day}, 2026</h2>
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-widest mt-1">Daily Schedule Overview</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedDay(null)} className="p-2 hover:bg-white/5 rounded-sm transition-colors text-muted-foreground hover:text-foreground">
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-[10px] font-bold uppercase tracking-widest text-primary mb-4 flex items-center gap-2">
|
|
||||||
<Clock className="h-3.5 w-3.5" /> Scheduled Events
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{selectedDay.events?.length > 0 ? (
|
|
||||||
selectedDay.events.map((event: any, idx: number) => (
|
|
||||||
<div key={idx} className="p-4 rounded-sm border border-white/5 bg-[#150F1D] flex items-center justify-between group">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-1 h-8 rounded-full ${event.colorClass.split(' ')[0]}`} />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold">{event.title}</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground">{event.time} • {event.type}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-white/5 rounded-sm transition-opacity">
|
|
||||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground italic px-2">No events scheduled for this day.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-white/5">
|
|
||||||
<h3 className="text-[10px] font-bold uppercase tracking-widest text-primary mb-4">Quick Add Event</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[10px] font-bold text-muted-foreground">TIME</label>
|
|
||||||
<input type="time" className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-3 py-2 text-xs outline-none focus:border-primary/50" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[10px] font-bold text-muted-foreground">CATEGORY</label>
|
|
||||||
<select className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-3 py-2 text-xs outline-none focus:border-primary/50">
|
|
||||||
{categories.map(c => <option key={c.name}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[10px] font-bold text-muted-foreground">EVENT TITLE</label>
|
|
||||||
<input type="text" placeholder="e.g., Team Sync" className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-3 py-2 text-xs outline-none focus:border-primary/50" />
|
|
||||||
</div>
|
|
||||||
<button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground py-3 rounded-sm text-xs font-bold tracking-widest shadow-lg shadow-primary/20 transition-all active:scale-[0.98]">
|
|
||||||
ADD TO CALENDAR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRelationName(relation: CalendarEventRow["clients"] | CalendarEventRow["projects"]) {
|
||||||
|
if (!relation) return null;
|
||||||
|
return Array.isArray(relation) ? relation[0]?.name || null : relation.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelationTitle(relation: CalendarEventRow["tasks"]) {
|
||||||
|
if (!relation) return null;
|
||||||
|
return Array.isArray(relation) ? relation[0]?.title || null : relation.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(type: string): CalendarEventItem["type"] {
|
||||||
|
if (
|
||||||
|
type === "meeting" ||
|
||||||
|
type === "deadline" ||
|
||||||
|
type === "personal" ||
|
||||||
|
type === "finance"
|
||||||
|
) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "focus";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user