Files
neta/app/(dashboard)/calendar/calendar-client.tsx
T

508 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
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,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
toast,
} from "poyraz-ui/molecules";
import { 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;
translations?: Record<string, Record<string, string>>;
};
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[];
activeLocales: { code: string; name: string }[];
};
export function CalendarClient({ events, clients, projects, tasks, activeLocales }: CalendarClientProps) {
const t = useTranslations();
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>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">{t("calendar.title")}</h1>
</div>
<CalendarEventDialog
mode="create"
defaultDate={selectedDate}
clients={clients}
projects={projects}
tasks={tasks}
activeLocales={activeLocales}
/>
</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">{t("common.itemsCount", { count: events.length })}</p>
</div>
<div className="flex gap-2">
<Button effect="shine" type="button" variant="secondary" onClick={() => shiftMonth(-1)}>
Önceki
</Button>
<Button effect="shine" type="button" variant="secondary" onClick={() => setMonthDate(new Date())}>
Bugün
</Button>
<Button effect="shine" type="button" variant="secondary" onClick={() => shiftMonth(1)}>
Sonraki
</Button>
</div>
</div>
<div className="overflow-x-auto pb-4">
<div className="min-w-[600px]">
<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 effect="shine"
key={day.key}
type="button"
variant={isSelected ? "default" : "secondary"}
onClick={() => setSelectedDate(day.key)}
radius="none"
className={`min-h-28 w-full justify-start whitespace-normal border-b border-r p-2 text-left last:border-r-0 ${
!day.inMonth ? "opacity-60" : ""
}`}
>
<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>
</div>
</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">
{t("common.itemsCount", { count: selectedEvents.length })}
</p>
</div>
<CalendarEventDialog
mode="create"
defaultDate={selectedDate}
clients={clients}
projects={projects}
tasks={tasks}
activeLocales={activeLocales}
/>
<EventList events={selectedEvents} clients={clients} projects={projects} tasks={tasks} activeLocales={activeLocales} />
</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} activeLocales={activeLocales} compact />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function EventList({
events,
clients,
projects,
tasks,
activeLocales,
compact = false,
}: {
events: CalendarEventItem[];
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
activeLocales: { code: string; name: string }[];
compact?: boolean;
}) {
const t = useTranslations();
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]}>{t(`calendar.types.${event.type}` as any)}</Badge>
</div>
{!compact ? (
<div className="mt-3 flex gap-2">
<CalendarEventDialog mode="edit" event={event} clients={clients} projects={projects} tasks={tasks} activeLocales={activeLocales} />
<form action={deleteCalendarEventRecord}>
<input type="hidden" name="id" value={event.id} />
<Button effect="shine" type="submit" variant="secondary" className="gap-2 text-rose-600">
<Trash2 className="h-4 w-4" />
{t("calendar.delete.confirm")}
</Button>
</form>
</div>
) : null}
</div>
))}
</div>
);
}
function CalendarEventDialog({
mode,
event,
defaultDate,
clients,
projects,
tasks,
activeLocales,
}: {
mode: "create" | "edit";
event?: CalendarEventItem;
defaultDate?: string;
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
activeLocales: { code: string; name: string }[];
}) {
const t = useTranslations();
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);
toast.success(mode === "create" ? t("calendar.messages.created") : t("calendar.messages.updated"));
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("calendar.errors.saveFailed"),
);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button effect="shine" className="gap-2" variant={mode === "create" ? "default" : "secondary"}>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? t("calendar.actions.add") : t("calendar.form.editTitle")}
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col overflow-hidden p-0 sm:max-h-[min(680px,calc(100dvh-4rem))] sm:max-w-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
<form action={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
{event ? <input type="hidden" name="id" value={event.id} /> : null}
<DialogHeader className="shrink-0 px-5 pb-4 pt-5 pr-12">
<DialogTitle>{mode === "create" ? t("calendar.form.createTitle") : t("calendar.form.editTitle")}</DialogTitle>
<DialogDescription>{t("calendar.description")}</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<EventFormFields event={event} defaultDate={defaultDate} clients={clients} projects={projects} tasks={tasks} activeLocales={activeLocales} />
</div>
<DialogFooter className="shrink-0 border-t border-border bg-background p-5">
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting} className="w-full gap-2 sm:w-auto">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isSubmitting ? "..." : t("calendar.form.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function EventFormFields({
event,
defaultDate,
clients,
projects,
tasks,
activeLocales,
}: {
event?: CalendarEventItem;
defaultDate?: string;
clients: CalendarRelationOption[];
projects: CalendarRelationOption[];
tasks: CalendarTaskOption[];
activeLocales: { code: string; name: string }[];
}) {
const t = useTranslations();
const startsAt = event?.starts_at ? toDateTimeLocal(event.starts_at) : `${defaultDate || toDateKey(new Date())}T09:00`;
return (
<div className="grid gap-4">
{activeLocales.length > 1 ? (
<Tabs defaultValue={activeLocales[0].code}>
<TabsList className="mb-4">
{activeLocales.map((locale) => (
<TabsTrigger key={locale.code} value={locale.code}>{locale.name}</TabsTrigger>
))}
</TabsList>
{activeLocales.map((locale) => (
<TabsContent key={locale.code} value={locale.code} className="space-y-4">
<div className="grid gap-2">
<Label>{t("calendar.form.title")} ({locale.code})</Label>
<Input name={`i18n.${locale.code}.title`} defaultValue={event?.translations?.[locale.code]?.title ?? ""} required={locale.code === activeLocales[0].code} />
</div>
<div className="grid gap-2">
<Label>{t("calendar.form.description")} ({locale.code})</Label>
<Textarea name={`i18n.${locale.code}.description`} defaultValue={event?.translations?.[locale.code]?.description ?? ""} rows={3} />
</div>
</TabsContent>
))}
</Tabs>
) : (
<div className="space-y-4">
<div className="grid gap-2">
<Label>{t("calendar.form.title")}</Label>
<Input name="title" defaultValue={event?.title || ""} required />
</div>
<div className="grid gap-2">
<Label>{t("calendar.form.description")}</Label>
<Textarea name="description" defaultValue={event?.description || ""} rows={3} />
</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<SelectField name="type" label={t("calendar.form.type") ?? "Tür"} defaultValue={event?.type || "focus"}>
<SelectItem value="meeting">{t("calendar.types.meeting")}</SelectItem>
<SelectItem value="focus">{t("calendar.types.focus")}</SelectItem>
<SelectItem value="deadline">{t("calendar.types.deadline")}</SelectItem>
<SelectItem value="personal">{t("calendar.types.personal")}</SelectItem>
<SelectItem value="finance">{t("calendar.types.finance")}</SelectItem>
</SelectField>
<SelectField name="client_id" label={t("calendar.form.client")} defaultValue={event?.client_id || "__none"}>
<SelectItem value="__none">{t("calendar.form.selectClient")}</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={t("calendar.form.project")} defaultValue={event?.project_id || "__none"}>
<SelectItem value="__none">{t("calendar.form.selectProject")}</SelectItem>
{projects.map((project) => <SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>)}
</SelectField>
<SelectField name="task_id" label={t("calendar.form.task")} defaultValue={event?.task_id || "__none"}>
<SelectItem value="__none">{t("calendar.form.selectTask")}</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>{t("calendar.form.start")}</Label>
<Input name="starts_at" type="datetime-local" defaultValue={startsAt} required />
</div>
<div className="grid gap-2">
<Label>{t("calendar.form.end")}</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(getDocumentIntlLocale(), { month: "long", year: "numeric" }).format(date);
}
function formatDateLabel(dateKey: string) {
return new Intl.DateTimeFormat(getDocumentIntlLocale(), { day: "2-digit", month: "long", year: "numeric" }).format(new Date(`${dateKey}T00:00:00`));
}
function formatTimeRange(event: CalendarEventItem) {
const start = new Intl.DateTimeFormat(getDocumentIntlLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(event.starts_at));
const end = event.ends_at ? new Intl.DateTimeFormat(getDocumentIntlLocale(), { 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);
}