feat(calendar): month-view calendar bound to calendar_events
Replaces the template's static-data calendar with a multi-tenant calendar backed by Appwrite calendar_events. Schema/validation: - lib/validation/calendar.ts (calendarEventSchema with cross-field check end >= start) - lib/appwrite/calendar-actions.ts: createCalendarEventAction, updateCalendarEventAction, deleteCalendarEventAction. Date inputs (HTML datetime-local 'YYYY-MM-DDTHH:mm', date 'YYYY-MM-DD') are normalized to ISO 8601 before write. - lib/appwrite/calendar-queries.ts: listCalendarEvents with optional start/end range queries. UI: - /calendar server page: pulls events + customers, hands to CalendarClient. - CalendarClient: month grid (6 rows × 7 cols), Monday-first, today badge, prev/next/Bugün nav. Multi-day events show on every day in their range. Each day cell shows up to 3 event chips with start time prefix; '+N daha' for overflow. Hover reveals a + button to add an event on that day. - EventFormSheet: title, all-day switch (toggles input type between date and datetime-local), start/end with validation, customer FK, color preset (blue/green/amber/red/violet/slate). Sentinel '__none__' for nullable Selects. When editing, footer shows a destructive 'Sil' ghost button on the left that triggers the parent's confirm dialog. Color tokens centralized in COLOR_BG map; falls back to primary tint. Removed all template calendar files (calendars.tsx, calendar-main, etc.) since the data model didn't match.
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { deleteCalendarEventAction } from "@/lib/appwrite/calendar-actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { EventFormSheet } from "./event-form-sheet";
|
||||||
|
import { COLOR_BG, type Customer, type EventRow } from "./types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
events: EventRow[];
|
||||||
|
customers: Customer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
|
||||||
|
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
|
||||||
|
];
|
||||||
|
|
||||||
|
function startOfMonthGrid(year: number, month: number): Date {
|
||||||
|
// Monday-first grid; first cell is the Monday on/before the 1st
|
||||||
|
const first = new Date(year, month, 1);
|
||||||
|
const dayIdx = (first.getDay() + 6) % 7; // 0 = Mon
|
||||||
|
return new Date(year, month, 1 - dayIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarClient({ events, customers }: Props) {
|
||||||
|
const today = new Date();
|
||||||
|
const [cursor, setCursor] = useState(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<EventRow | null>(null);
|
||||||
|
const [defaultDate, setDefaultDate] = useState<string | undefined>();
|
||||||
|
const [deleting, setDeleting] = useState<EventRow | null>(null);
|
||||||
|
const [busy, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const eventsByDay = useMemo(() => {
|
||||||
|
const map = new Map<string, EventRow[]>();
|
||||||
|
for (const e of events) {
|
||||||
|
const start = new Date(e.start);
|
||||||
|
const end = new Date(e.end);
|
||||||
|
const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
||||||
|
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
|
||||||
|
while (cur.getTime() <= last.getTime()) {
|
||||||
|
const key = ymd(cur);
|
||||||
|
const arr = map.get(key) ?? [];
|
||||||
|
arr.push(e);
|
||||||
|
map.set(key, arr);
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const start = startOfMonthGrid(cursor.getFullYear(), cursor.getMonth());
|
||||||
|
const days: Date[] = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(start.getDate() + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
const handlePrev = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
|
||||||
|
const handleNext = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));
|
||||||
|
const handleToday = () => setCursor(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
|
||||||
|
const handleAddOnDay = (date: Date) => {
|
||||||
|
setEditing(null);
|
||||||
|
setDefaultDate(ymd(date));
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setDefaultDate(ymd(today));
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (event: EventRow) => {
|
||||||
|
setEditing(event);
|
||||||
|
setDefaultDate(undefined);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("id", deleting.id);
|
||||||
|
const result = await deleteCalendarEventAction(fd);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success("Etkinlik silindi.");
|
||||||
|
setDeleting(null);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Silme başarısız.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayKey = ymd(today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-4 flex flex-col items-center justify-between gap-3 md:flex-row">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="icon" className="size-8" onClick={handlePrev}>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{MONTH_NAMES[cursor.getMonth()]} {cursor.getFullYear()}
|
||||||
|
</h2>
|
||||||
|
<Button variant="outline" size="icon" className="size-8" onClick={handleNext}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleToday}>
|
||||||
|
Bugün
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddNew}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Yeni etkinlik
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-md border bg-border">
|
||||||
|
{WEEKDAYS.map((wd) => (
|
||||||
|
<div
|
||||||
|
key={wd}
|
||||||
|
className="bg-muted/40 text-muted-foreground py-2 text-center text-xs font-medium"
|
||||||
|
>
|
||||||
|
{wd}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{grid.map((d) => {
|
||||||
|
const inMonth = d.getMonth() === cursor.getMonth();
|
||||||
|
const key = ymd(d);
|
||||||
|
const isToday = key === todayKey;
|
||||||
|
const dayEvents = eventsByDay.get(key) ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"bg-card group relative flex min-h-[110px] flex-col gap-1 p-1.5",
|
||||||
|
!inMonth && "bg-muted/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-6 items-center justify-center rounded-full text-xs",
|
||||||
|
isToday && "bg-primary text-primary-foreground font-medium",
|
||||||
|
!inMonth && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.getDate()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddOnDay(d)}
|
||||||
|
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100"
|
||||||
|
aria-label="Bu güne etkinlik ekle"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{dayEvents.slice(0, 3).map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(e)}
|
||||||
|
className={cn(
|
||||||
|
"truncate rounded border px-1.5 py-0.5 text-left text-xs",
|
||||||
|
COLOR_BG[e.color] ?? COLOR_BG[""],
|
||||||
|
)}
|
||||||
|
title={e.title}
|
||||||
|
>
|
||||||
|
{!e.allDay && (
|
||||||
|
<span className="opacity-70">
|
||||||
|
{new Date(e.start).toLocaleTimeString("tr-TR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 3 && (
|
||||||
|
<span className="text-muted-foreground px-1 text-xs">
|
||||||
|
+{dayEvents.length - 3} daha
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<EventFormSheet
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setFormOpen(v);
|
||||||
|
if (!v) setEditing(null);
|
||||||
|
}}
|
||||||
|
event={editing}
|
||||||
|
defaultDate={defaultDate}
|
||||||
|
customers={customers}
|
||||||
|
onRequestDelete={(e) => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setDeleting(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Etkinliği sil</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
|
||||||
|
Vazgeç
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||||
|
Sil
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Calendar as CalendarIcon,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Users,
|
|
||||||
MoreHorizontal,
|
|
||||||
Search,
|
|
||||||
Grid3X3,
|
|
||||||
List,
|
|
||||||
ChevronDown,
|
|
||||||
Menu
|
|
||||||
} from "lucide-react"
|
|
||||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { type CalendarEvent } from "../types"
|
|
||||||
|
|
||||||
// Import data
|
|
||||||
import eventsData from "../data/events.json"
|
|
||||||
|
|
||||||
interface CalendarMainProps {
|
|
||||||
selectedDate?: Date
|
|
||||||
onDateSelect?: (date: Date) => void
|
|
||||||
onMenuClick?: () => void
|
|
||||||
events?: CalendarEvent[]
|
|
||||||
onEventClick?: (event: CalendarEvent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarMain({ selectedDate, onDateSelect, onMenuClick, events, onEventClick }: CalendarMainProps) {
|
|
||||||
// Convert JSON events to CalendarEvent objects with proper Date objects, fallback to imported data
|
|
||||||
const sampleEvents: CalendarEvent[] = events || eventsData.map(event => ({
|
|
||||||
...event,
|
|
||||||
date: new Date(event.date),
|
|
||||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [currentDate, setCurrentDate] = useState(selectedDate || new Date())
|
|
||||||
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
|
||||||
const [showEventDialog, setShowEventDialog] = useState(false)
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentDate)
|
|
||||||
const monthEnd = endOfMonth(currentDate)
|
|
||||||
|
|
||||||
// Extend to show full weeks (including previous/next month days)
|
|
||||||
const calendarStart = new Date(monthStart)
|
|
||||||
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
|
||||||
|
|
||||||
const calendarEnd = new Date(monthEnd)
|
|
||||||
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
|
||||||
|
|
||||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
|
||||||
|
|
||||||
const getEventsForDay = (date: Date) => {
|
|
||||||
return sampleEvents.filter(event => isSameDay(event.date, date))
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateMonth = (direction: "prev" | "next") => {
|
|
||||||
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToToday = () => {
|
|
||||||
setCurrentDate(new Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEventClick = (event: CalendarEvent) => {
|
|
||||||
if (onEventClick) {
|
|
||||||
onEventClick(event)
|
|
||||||
} else {
|
|
||||||
setSelectedEvent(event)
|
|
||||||
setShowEventDialog(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCalendarGrid = () => {
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 bg-background">
|
|
||||||
{/* Calendar Header */}
|
|
||||||
<div className="grid grid-cols-7 border-b">
|
|
||||||
{weekDays.map(day => (
|
|
||||||
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Body */}
|
|
||||||
<div className="grid grid-cols-7 flex-1">
|
|
||||||
{calendarDays.map(day => {
|
|
||||||
const dayEvents = getEventsForDay(day)
|
|
||||||
const isCurrentMonth = isSameMonth(day, currentDate)
|
|
||||||
const isDayToday = isToday(day)
|
|
||||||
const isSelected = selectedDate && isSameDay(day, selectedDate)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day.toISOString()}
|
|
||||||
className={cn(
|
|
||||||
"min-h-[120px] border-r border-b last:border-r-0 p-2 cursor-pointer transition-colors",
|
|
||||||
isCurrentMonth ? "bg-background hover:bg-accent/50" : "bg-muted/30 text-muted-foreground",
|
|
||||||
isSelected && "ring-2 ring-primary ring-inset",
|
|
||||||
isDayToday && "bg-accent/20"
|
|
||||||
)}
|
|
||||||
onClick={() => onDateSelect?.(day)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className={cn(
|
|
||||||
"text-sm font-medium",
|
|
||||||
isDayToday && "bg-primary text-primary-foreground rounded-md w-6 h-6 flex items-center justify-center text-xs"
|
|
||||||
)}>
|
|
||||||
{format(day, 'd')}
|
|
||||||
</span>
|
|
||||||
{dayEvents.length > 2 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
+{dayEvents.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dayEvents.slice(0, 2).map(event => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className={cn(
|
|
||||||
"text-xs p-1 rounded-sm text-white cursor-pointer truncate",
|
|
||||||
event.color
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleEventClick(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
<span className="truncate">{event.title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderListView = () => {
|
|
||||||
const upcomingEvents = sampleEvents
|
|
||||||
.filter(event => event.date >= new Date())
|
|
||||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{upcomingEvents.map(event => (
|
|
||||||
<Card key={event.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleEventClick(event)}>
|
|
||||||
<CardContent className="px-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className={cn("w-3 h-3 rounded-full mt-1.5", event.color)} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">{event.title}</h3>
|
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center flex-wrap gap-1">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{format(event.date, 'MMM d, yyyy')}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center flex-wrap gap-1">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
{event.time}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center flex-wrap gap-1">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
{event.location}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex -space-x-2">
|
|
||||||
{event.attendees.slice(0, 3).map((attendee, index) => (
|
|
||||||
<Avatar key={index} className="border-2 border-background">
|
|
||||||
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="cursor-pointer">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col flex-wrap gap-4 p-6 border-b md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="xl:hidden cursor-pointer"
|
|
||||||
onClick={onMenuClick}
|
|
||||||
>
|
|
||||||
<Menu className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => navigateMonth("prev")} className="cursor-pointer">
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => navigateMonth("next")} className="cursor-pointer">
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={goToToday} className="cursor-pointer">
|
|
||||||
Today
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold">
|
|
||||||
{format(currentDate, 'MMMM yyyy')}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input placeholder="Search events..." className="pl-10 w-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" className="cursor-pointer">
|
|
||||||
{viewMode === "month" && <Grid3X3 className="w-4 h-4 mr-2" />}
|
|
||||||
{viewMode === "list" && <List className="w-4 h-4 mr-2" />}
|
|
||||||
{viewMode.charAt(0).toUpperCase() + viewMode.slice(1)}
|
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("month")} className="cursor-pointer">
|
|
||||||
<Grid3X3 className="w-4 h-4 mr-2" />
|
|
||||||
Month
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("list")} className="cursor-pointer">
|
|
||||||
<List className="w-4 h-4 mr-2" />
|
|
||||||
List
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Content */}
|
|
||||||
{viewMode === "month" ? renderCalendarGrid() : renderListView()}
|
|
||||||
|
|
||||||
{/* Event Detail Dialog */}
|
|
||||||
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{selectedEvent?.title || "Event Details"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
View and manage this calendar event
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{selectedEvent && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span>{format(selectedEvent.date, 'EEEE, MMMM d, yyyy')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span>{selectedEvent.time} ({selectedEvent.duration})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span>{selectedEvent.location}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>Attendees:</span>
|
|
||||||
<div className="flex -space-x-2">
|
|
||||||
{selectedEvent.attendees.map((attendee: string, index: number) => (
|
|
||||||
<Avatar key={index} className="w-6 h-6 border-2 border-background">
|
|
||||||
<AvatarFallback className="text-xs">{attendee}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
|
||||||
{selectedEvent.type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button variant="outline" className="flex-1 cursor-pointer" onClick={() => {
|
|
||||||
setShowEventDialog(false)
|
|
||||||
}}>Edit</Button>
|
|
||||||
<Button variant="destructive" className="flex-1 cursor-pointer" onClick={() => {
|
|
||||||
setShowEventDialog(false)
|
|
||||||
}}>Delete</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Plus } from "lucide-react"
|
|
||||||
|
|
||||||
import { Calendars } from "./calendars"
|
|
||||||
import { DatePicker } from "./date-picker"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
interface CalendarSidebarProps {
|
|
||||||
selectedDate?: Date
|
|
||||||
onDateSelect?: (date: Date) => void
|
|
||||||
onNewCalendar?: () => void
|
|
||||||
onNewEvent?: () => void
|
|
||||||
events?: Array<{ date: Date; count: number }>
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarSidebar({
|
|
||||||
selectedDate,
|
|
||||||
onDateSelect,
|
|
||||||
onNewCalendar,
|
|
||||||
onNewEvent,
|
|
||||||
events = [],
|
|
||||||
className
|
|
||||||
}: CalendarSidebarProps) {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col h-full bg-background rounded-lg ${className}`}>
|
|
||||||
{/* Add New Event Button */}
|
|
||||||
<div className="p-6 border-b">
|
|
||||||
<Button
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
onClick={onNewEvent}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add New Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Picker */}
|
|
||||||
<DatePicker
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
onDateSelect={onDateSelect}
|
|
||||||
events={events}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Calendars */}
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<Calendars
|
|
||||||
onNewCalendar={onNewCalendar}
|
|
||||||
onCalendarToggle={(calendarId, visible) => {
|
|
||||||
console.log(`Calendar ${calendarId} visibility: ${visible}`)
|
|
||||||
}}
|
|
||||||
onCalendarEdit={(calendarId) => {
|
|
||||||
console.log(`Edit calendar: ${calendarId}`)
|
|
||||||
}}
|
|
||||||
onCalendarDelete={(calendarId) => {
|
|
||||||
console.log(`Delete calendar: ${calendarId}`)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
onClick={onNewCalendar}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Calendar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Calendar as CalendarIcon,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Users,
|
|
||||||
Search,
|
|
||||||
Grid3X3,
|
|
||||||
List,
|
|
||||||
ChevronDown,
|
|
||||||
Menu,
|
|
||||||
Plus
|
|
||||||
} from "lucide-react"
|
|
||||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from "date-fns"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { type CalendarEvent } from "../types"
|
|
||||||
|
|
||||||
// Import data
|
|
||||||
import eventsData from "../data/events.json"
|
|
||||||
import calendarsData from "../data/calendars.json"
|
|
||||||
|
|
||||||
interface CalendarMainProps {
|
|
||||||
eventDates?: Array<{ date: Date; count: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarMain({ eventDates = [] }: CalendarMainProps) {
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
|
||||||
const [viewMode, setViewMode] = useState<"month" | "week" | "day" | "list">("month")
|
|
||||||
const [showEventDialog, setShowEventDialog] = useState(false)
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
|
||||||
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
|
||||||
|
|
||||||
// Convert JSON events to CalendarEvent objects with proper Date objects
|
|
||||||
const sampleEvents: CalendarEvent[] = eventsData.map(event => ({
|
|
||||||
...event,
|
|
||||||
date: new Date(event.date),
|
|
||||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
|
||||||
}))
|
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentDate)
|
|
||||||
const monthEnd = endOfMonth(currentDate)
|
|
||||||
|
|
||||||
// Extend to show full weeks (including previous/next month days)
|
|
||||||
const calendarStart = new Date(monthStart)
|
|
||||||
calendarStart.setDate(calendarStart.getDate() - monthStart.getDay())
|
|
||||||
|
|
||||||
const calendarEnd = new Date(monthEnd)
|
|
||||||
calendarEnd.setDate(calendarEnd.getDate() + (6 - monthEnd.getDay()))
|
|
||||||
|
|
||||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
|
|
||||||
|
|
||||||
const getEventsForDay = (date: Date) => {
|
|
||||||
return sampleEvents.filter(event => isSameDay(event.date, date))
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateMonth = (direction: "prev" | "next") => {
|
|
||||||
setCurrentDate(direction === "prev" ? subMonths(currentDate, 1) : addMonths(currentDate, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToToday = () => {
|
|
||||||
setCurrentDate(new Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEventClick = (event: CalendarEvent) => {
|
|
||||||
setSelectedEvent(event)
|
|
||||||
setShowEventDialog(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
|
||||||
setSelectedDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewCalendar = () => {
|
|
||||||
console.log("Creating new calendar")
|
|
||||||
// In a real app, this would open a new calendar form
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewEvent = () => {
|
|
||||||
console.log("Creating new event")
|
|
||||||
// In a real app, this would open event form
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCalendarGrid = () => {
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 bg-background">
|
|
||||||
{/* Calendar Header */}
|
|
||||||
<div className="grid grid-cols-7 border-b">
|
|
||||||
{weekDays.map(day => (
|
|
||||||
<div key={day} className="p-4 text-center font-medium text-sm text-muted-foreground border-r last:border-r-0">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
<div className="grid grid-cols-7 min-h-[600px]">
|
|
||||||
{calendarDays.map((day) => {
|
|
||||||
const dayEvents = getEventsForDay(day)
|
|
||||||
const isCurrentMonth = isSameMonth(day, currentDate)
|
|
||||||
const isDayToday = isToday(day)
|
|
||||||
const isSelected = isSameDay(day, selectedDate)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={day.toISOString()}
|
|
||||||
className={cn(
|
|
||||||
"relative border-r border-b last:border-r-0 p-2 min-h-[120px] hover:bg-muted/50 cursor-pointer transition-colors",
|
|
||||||
!isCurrentMonth && "text-muted-foreground bg-muted/20",
|
|
||||||
isDayToday && "bg-blue-50 dark:bg-blue-900/20",
|
|
||||||
isSelected && "bg-blue-100 dark:bg-blue-800/30"
|
|
||||||
)}
|
|
||||||
onClick={() => handleDateSelect(day)}
|
|
||||||
>
|
|
||||||
{/* Date Number */}
|
|
||||||
<div className={cn(
|
|
||||||
"text-sm font-medium mb-1",
|
|
||||||
isDayToday && "text-blue-600 dark:text-blue-400"
|
|
||||||
)}>
|
|
||||||
{format(day, 'd')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Events */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dayEvents.slice(0, 3).map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className={cn(
|
|
||||||
"text-xs px-2 py-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity truncate",
|
|
||||||
event.color
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleEventClick(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{event.time} {event.title}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{dayEvents.length > 3 && (
|
|
||||||
<div className="text-xs text-muted-foreground px-2">
|
|
||||||
+{dayEvents.length - 3} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSidebar = () => (
|
|
||||||
<div className="w-full h-full bg-background border-r">
|
|
||||||
<div className="p-4 border-b">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="font-semibold">Calendar</h2>
|
|
||||||
<Button size="sm" onClick={handleNewEvent}>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Picker */}
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={selectedDate}
|
|
||||||
onSelect={(date) => date && handleDateSelect(date)}
|
|
||||||
className="rounded-md border"
|
|
||||||
modifiers={{
|
|
||||||
eventDay: eventDates.map(ed => ed.date)
|
|
||||||
}}
|
|
||||||
modifiersStyles={{
|
|
||||||
eventDay: { fontWeight: 'bold' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini Calendars List */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-medium">My Calendars</h3>
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleNewCalendar}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{calendarsData.map((calendar) => (
|
|
||||||
<div key={calendar.id} className="flex items-center space-x-2">
|
|
||||||
<div className={cn("w-3 h-3 rounded-full", calendar.color)} />
|
|
||||||
<span className="text-sm">{calendar.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg bg-background relative">
|
|
||||||
<div className="flex min-h-[800px]">
|
|
||||||
{/* Desktop Sidebar */}
|
|
||||||
<div className="hidden xl:block w-80 flex-shrink-0">
|
|
||||||
{renderSidebar()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Calendar Panel */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Calendar Toolbar */}
|
|
||||||
<div className="border-b bg-background px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="xl:hidden"
|
|
||||||
onClick={() => setShowCalendarSheet(true)}
|
|
||||||
>
|
|
||||||
<Menu className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Month Navigation */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth("prev")}>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="text-lg font-semibold min-w-[140px] text-center">
|
|
||||||
{format(currentDate, 'MMMM yyyy')}
|
|
||||||
</h2>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth("next")}>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={goToToday}>
|
|
||||||
Today
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="hidden sm:flex items-center space-x-2">
|
|
||||||
<Button variant="ghost" size="sm" className="text-xs">
|
|
||||||
<Search className="h-4 w-4 mr-1" />
|
|
||||||
Search
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Grid3X3 className="h-4 w-4 mr-1" />
|
|
||||||
{viewMode === "month" ? "Month" : viewMode === "week" ? "Week" : viewMode === "day" ? "Day" : "List"}
|
|
||||||
<ChevronDown className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("month")}>
|
|
||||||
<Grid3X3 className="h-4 w-4 mr-2" />
|
|
||||||
Month
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("week")}>
|
|
||||||
<List className="h-4 w-4 mr-2" />
|
|
||||||
Week
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("day")}>
|
|
||||||
<CalendarIcon className="h-4 w-4 mr-2" />
|
|
||||||
Day
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
|
||||||
<List className="h-4 w-4 mr-2" />
|
|
||||||
List
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Content */}
|
|
||||||
{renderCalendarGrid()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile/Tablet Sheet */}
|
|
||||||
<Sheet open={showCalendarSheet} onOpenChange={setShowCalendarSheet}>
|
|
||||||
<SheetContent side="left" className="w-80 p-0">
|
|
||||||
<SheetHeader className="p-4 pb-2">
|
|
||||||
<SheetTitle>Calendar</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Browse dates and manage your calendar events
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
{renderSidebar()}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
{/* Event Details Dialog */}
|
|
||||||
<Dialog open={showEventDialog} onOpenChange={setShowEventDialog}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{selectedEvent?.title}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Event details and information
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{selectedEvent && (
|
|
||||||
<div className="space-y-4 pt-4">
|
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>{selectedEvent.time} • {selectedEvent.duration}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedEvent.location && (
|
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
|
||||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>{selectedEvent.location}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEvent.attendees.length > 0 && (
|
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
{selectedEvent.attendees.map((attendee, index) => (
|
|
||||||
<Avatar key={index} className="h-6 w-6">
|
|
||||||
<AvatarFallback className="text-xs">
|
|
||||||
{attendee}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEvent.description && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{selectedEvent.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 pt-4">
|
|
||||||
<Badge variant="secondary" className={cn("text-white", selectedEvent.color)}>
|
|
||||||
{selectedEvent.type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { CalendarSidebar } from "./calendar-sidebar"
|
|
||||||
import { CalendarMain } from "./calendar-main"
|
|
||||||
import { EventForm } from "./event-form"
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import { type CalendarEvent } from "../types"
|
|
||||||
import { useCalendar } from "../use-calendar"
|
|
||||||
|
|
||||||
interface CalendarProps {
|
|
||||||
events: CalendarEvent[]
|
|
||||||
eventDates: Array<{ date: Date; count: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Calendar({ events, eventDates }: CalendarProps) {
|
|
||||||
const calendar = useCalendar(events)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="border rounded-lg bg-background relative">
|
|
||||||
<div className="flex min-h-[800px]">
|
|
||||||
{/* Desktop Sidebar - Hidden on mobile/tablet, shown on extra large screens */}
|
|
||||||
<div className="hidden xl:block w-80 flex-shrink-0 border-r">
|
|
||||||
<CalendarSidebar
|
|
||||||
selectedDate={calendar.selectedDate}
|
|
||||||
onDateSelect={calendar.handleDateSelect}
|
|
||||||
onNewCalendar={calendar.handleNewCalendar}
|
|
||||||
onNewEvent={calendar.handleNewEvent}
|
|
||||||
events={eventDates}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Calendar Panel */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CalendarMain
|
|
||||||
selectedDate={calendar.selectedDate}
|
|
||||||
onDateSelect={calendar.handleDateSelect}
|
|
||||||
onMenuClick={() => calendar.setShowCalendarSheet(true)}
|
|
||||||
events={calendar.events}
|
|
||||||
onEventClick={calendar.handleEditEvent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile/Tablet Sheet - Positioned relative to calendar container */}
|
|
||||||
<Sheet open={calendar.showCalendarSheet} onOpenChange={calendar.setShowCalendarSheet}>
|
|
||||||
<SheetContent side="left" className="w-80 p-0" style={{ position: 'absolute' }}>
|
|
||||||
<SheetHeader className="p-4 pb-2">
|
|
||||||
<SheetTitle>Calendar</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Browse dates and manage your calendar events
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<CalendarSidebar
|
|
||||||
selectedDate={calendar.selectedDate}
|
|
||||||
onDateSelect={calendar.handleDateSelect}
|
|
||||||
onNewCalendar={calendar.handleNewCalendar}
|
|
||||||
onNewEvent={calendar.handleNewEvent}
|
|
||||||
events={eventDates}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Event Form Dialog */}
|
|
||||||
<EventForm
|
|
||||||
event={calendar.editingEvent}
|
|
||||||
open={calendar.showEventForm}
|
|
||||||
onOpenChange={calendar.setShowEventForm}
|
|
||||||
onSave={calendar.handleSaveEvent}
|
|
||||||
onDelete={calendar.handleDeleteEvent}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Check, ChevronRight, Plus, Eye, EyeOff, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface CalendarItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
visible: boolean
|
|
||||||
type: "personal" | "work" | "shared"
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalendarGroup {
|
|
||||||
name: string
|
|
||||||
items: CalendarItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalendarsProps {
|
|
||||||
calendars?: {
|
|
||||||
name: string
|
|
||||||
items: string[]
|
|
||||||
}[]
|
|
||||||
onCalendarToggle?: (calendarId: string, visible: boolean) => void
|
|
||||||
onCalendarEdit?: (calendarId: string) => void
|
|
||||||
onCalendarDelete?: (calendarId: string) => void
|
|
||||||
onNewCalendar?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced calendar data with colors and visibility
|
|
||||||
const enhancedCalendars: CalendarGroup[] = [
|
|
||||||
{
|
|
||||||
name: "My Calendars",
|
|
||||||
items: [
|
|
||||||
{ id: "personal", name: "Personal", color: "bg-blue-500", visible: true, type: "personal" },
|
|
||||||
{ id: "work", name: "Work", color: "bg-green-500", visible: true, type: "work" },
|
|
||||||
{ id: "family", name: "Family", color: "bg-pink-500", visible: true, type: "personal" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Favorites",
|
|
||||||
items: [
|
|
||||||
{ id: "holidays", name: "Holidays", color: "bg-red-500", visible: true, type: "shared" },
|
|
||||||
{ id: "birthdays", name: "Birthdays", color: "bg-purple-500", visible: true, type: "personal" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Other",
|
|
||||||
items: [
|
|
||||||
{ id: "travel", name: "Travel", color: "bg-orange-500", visible: false, type: "personal" },
|
|
||||||
{ id: "reminders", name: "Reminders", color: "bg-yellow-500", visible: true, type: "personal" },
|
|
||||||
{ id: "deadlines", name: "Deadlines", color: "bg-red-600", visible: true, type: "work" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Calendars({
|
|
||||||
onCalendarToggle,
|
|
||||||
onCalendarEdit,
|
|
||||||
onCalendarDelete,
|
|
||||||
onNewCalendar
|
|
||||||
}: CalendarsProps) {
|
|
||||||
const [calendarData, setCalendarData] = useState(enhancedCalendars)
|
|
||||||
|
|
||||||
const handleToggleVisibility = (calendarId: string) => {
|
|
||||||
setCalendarData(prev => prev.map(group => ({
|
|
||||||
...group,
|
|
||||||
items: group.items.map(item =>
|
|
||||||
item.id === calendarId
|
|
||||||
? { ...item, visible: !item.visible }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
})))
|
|
||||||
|
|
||||||
const calendar = calendarData.flatMap(g => g.items).find(c => c.id === calendarId)
|
|
||||||
if (calendar) {
|
|
||||||
onCalendarToggle?.(calendarId, !calendar.visible)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{calendarData.map((calendar, index) => (
|
|
||||||
<div key={calendar.name}>
|
|
||||||
<Collapsible
|
|
||||||
defaultOpen={index === 0}
|
|
||||||
className="group/collapsible"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-md cursor-pointer">
|
|
||||||
<span className="text-sm font-medium">{calendar.name}</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{index === 0 && (
|
|
||||||
<div
|
|
||||||
className="h-5 w-5 flex items-center justify-center opacity-0 group-hover/collapsible:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onNewCalendar?.()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{calendar.items.map((item) => (
|
|
||||||
<div key={item.id} className="group/calendar-item">
|
|
||||||
<div className="flex items-center justify-between p-2 hover:bg-accent/50 rounded-md">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
{/* Calendar Color & Visibility Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleVisibility(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex aspect-square size-4 shrink-0 items-center justify-center rounded-sm border transition-all cursor-pointer",
|
|
||||||
item.visible
|
|
||||||
? cn("border-transparent text-white", item.color)
|
|
||||||
: "border-border bg-transparent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.visible && <Check className="size-3" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Calendar Name */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-1 truncate text-sm cursor-pointer",
|
|
||||||
!item.visible && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
onClick={() => handleToggleVisibility(item.id)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Visibility Icon */}
|
|
||||||
<div className="opacity-0 group-hover/calendar-item:opacity-100">
|
|
||||||
{item.visible ? (
|
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* More Options */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
className="h-5 w-5 flex items-center justify-center p-0 opacity-0 group-hover/calendar-item:opacity-100 cursor-pointer hover:bg-accent rounded-sm"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" side="right">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCalendarEdit?.(item.id)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Edit calendar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleToggleVisibility(item.id)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{item.visible ? "Hide" : "Show"} calendar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCalendarDelete?.(item.id)}
|
|
||||||
className="cursor-pointer text-destructive"
|
|
||||||
>
|
|
||||||
Delete calendar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
|
|
||||||
interface DatePickerProps {
|
|
||||||
selectedDate?: Date
|
|
||||||
onDateSelect?: (date: Date) => void
|
|
||||||
events?: Array<{ date: Date; count: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DatePicker({ selectedDate, onDateSelect, events = [] }: DatePickerProps) {
|
|
||||||
const [date, setDate] = useState<Date | undefined>(selectedDate || new Date())
|
|
||||||
|
|
||||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
|
||||||
if (selectedDate) {
|
|
||||||
setDate(selectedDate)
|
|
||||||
onDateSelect?.(selectedDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a map of dates with events for styling
|
|
||||||
const eventDates = events.reduce((acc, event) => {
|
|
||||||
const dateKey = event.date.toDateString()
|
|
||||||
acc[dateKey] = event.count
|
|
||||||
return acc
|
|
||||||
}, {} as Record<string, number>)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={date}
|
|
||||||
onSelect={handleDateSelect}
|
|
||||||
className="w-full [&_[role=gridcell]_button]:cursor-pointer [&_button]:cursor-pointer"
|
|
||||||
modifiers={{
|
|
||||||
hasEvents: (date) => {
|
|
||||||
const eventCount = eventDates[date.toDateString()]
|
|
||||||
return Boolean(eventCount && eventCount > 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
modifiersClassNames={{
|
|
||||||
hasEvents: "relative after:absolute after:bottom-1 after:right-1 after:w-1.5 after:h-1.5 after:bg-primary after:rounded-full"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { Loader2, Save, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
createCalendarEventAction,
|
||||||
|
updateCalendarEventAction,
|
||||||
|
} from "@/lib/appwrite/calendar-actions";
|
||||||
|
import { initialCalendarState } from "@/lib/appwrite/calendar-types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { COLOR_PRESETS, type Customer, type EventRow } from "./types";
|
||||||
|
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
event?: EventRow | null;
|
||||||
|
defaultDate?: string; // YYYY-MM-DD for new events
|
||||||
|
customers: Customer[];
|
||||||
|
onRequestDelete?: (event: EventRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isoToInput(iso: string, allDay: boolean): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
if (allDay) return iso.slice(0, 10);
|
||||||
|
const d = new Date(iso);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventFormSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
event,
|
||||||
|
defaultDate,
|
||||||
|
customers,
|
||||||
|
onRequestDelete,
|
||||||
|
}: Props) {
|
||||||
|
const isEdit = Boolean(event);
|
||||||
|
const action = isEdit ? updateCalendarEventAction : createCalendarEventAction;
|
||||||
|
const [state, formAction, isPending] = useActionState(action, initialCalendarState);
|
||||||
|
const [allDay, setAllDay] = useState<boolean>(event?.allDay ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllDay(event?.allDay ?? false);
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success(isEdit ? "Etkinlik güncellendi." : "Etkinlik eklendi.");
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const startDefault =
|
||||||
|
event?.start
|
||||||
|
? isoToInput(event.start, allDay)
|
||||||
|
: defaultDate
|
||||||
|
? allDay
|
||||||
|
? defaultDate
|
||||||
|
: `${defaultDate}T09:00`
|
||||||
|
: "";
|
||||||
|
const endDefault =
|
||||||
|
event?.end
|
||||||
|
? isoToInput(event.end, allDay)
|
||||||
|
: defaultDate
|
||||||
|
? allDay
|
||||||
|
? defaultDate
|
||||||
|
: `${defaultDate}T10:00`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||||
|
<SheetHeader className="border-b px-6 py-4">
|
||||||
|
<SheetTitle>{isEdit ? "Etkinliği düzenle" : "Yeni etkinlik"}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Tarih, saat ve müşteri bilgileri ile bir takvim girdisi oluşturun.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
["customerId", "color"].forEach((k) => {
|
||||||
|
if (fd.get(k) === NONE) fd.set(k, "");
|
||||||
|
});
|
||||||
|
formAction(fd);
|
||||||
|
}}
|
||||||
|
className="flex flex-1 flex-col"
|
||||||
|
>
|
||||||
|
{isEdit && event && <input type="hidden" name="id" value={event.id} />}
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">Başlık *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
defaultValue={event?.title ?? ""}
|
||||||
|
placeholder="Örn. Müşteri toplantısı"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.title && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<Label htmlFor="allDay" className="cursor-pointer">
|
||||||
|
Tüm gün
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">Saat girmeden gün boyu sürecek.</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="allDay"
|
||||||
|
name="allDay"
|
||||||
|
checked={allDay}
|
||||||
|
onCheckedChange={setAllDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="start">Başlangıç *</Label>
|
||||||
|
<Input
|
||||||
|
id="start"
|
||||||
|
name="start"
|
||||||
|
type={allDay ? "date" : "datetime-local"}
|
||||||
|
defaultValue={startDefault}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.start && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.start}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="end">Bitiş *</Label>
|
||||||
|
<Input
|
||||||
|
id="end"
|
||||||
|
name="end"
|
||||||
|
type={allDay ? "date" : "datetime-local"}
|
||||||
|
defaultValue={endDefault}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.end && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.end}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
|
||||||
|
<Select name="customerId" defaultValue={event?.customerId || NONE}>
|
||||||
|
<SelectTrigger id="customerId">
|
||||||
|
<SelectValue placeholder="Yok" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>Yok</SelectItem>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="color">Renk</Label>
|
||||||
|
<Select name="color" defaultValue={event?.color || NONE}>
|
||||||
|
<SelectTrigger id="color">
|
||||||
|
<SelectValue placeholder="Varsayılan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>Varsayılan</SelectItem>
|
||||||
|
{COLOR_PRESETS.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={cn("size-3 rounded-full", c.classes)} />
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">Notlar</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={event?.description ?? ""}
|
||||||
|
placeholder="Açıklama, gündem, vb."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
{isEdit && event && onRequestDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onRequestDelete(event)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Sil
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Vazgeç
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Kaydediliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{isEdit ? "Güncelle" : "Kaydet"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { CalendarIcon, Clock, MapPin, Users, Type, Tag } from "lucide-react"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { type CalendarEvent } from "../types"
|
|
||||||
|
|
||||||
interface EventFormProps {
|
|
||||||
event?: CalendarEvent | null
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSave: (event: Partial<CalendarEvent>) => void
|
|
||||||
onDelete?: (eventId: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTypes = [
|
|
||||||
{ value: "meeting", label: "Meeting", color: "bg-blue-500" },
|
|
||||||
{ value: "event", label: "Event", color: "bg-green-500" },
|
|
||||||
{ value: "personal", label: "Personal", color: "bg-pink-500" },
|
|
||||||
{ value: "task", label: "Task", color: "bg-orange-500" },
|
|
||||||
{ value: "reminder", label: "Reminder", color: "bg-purple-500" }
|
|
||||||
]
|
|
||||||
|
|
||||||
const timeSlots = [
|
|
||||||
"9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM", "11:00 AM", "11:30 AM",
|
|
||||||
"12:00 PM", "12:30 PM", "1:00 PM", "1:30 PM", "2:00 PM", "2:30 PM",
|
|
||||||
"3:00 PM", "3:30 PM", "4:00 PM", "4:30 PM", "5:00 PM", "5:30 PM",
|
|
||||||
"6:00 PM", "6:30 PM", "7:00 PM", "7:30 PM", "8:00 PM", "8:30 PM"
|
|
||||||
]
|
|
||||||
|
|
||||||
const durationOptions = [
|
|
||||||
"15 min", "30 min", "45 min", "1 hour", "1.5 hours", "2 hours", "3 hours", "All day"
|
|
||||||
]
|
|
||||||
|
|
||||||
export function EventForm({ event, open, onOpenChange, onSave, onDelete }: EventFormProps) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: event?.title || "",
|
|
||||||
date: event?.date || new Date(),
|
|
||||||
time: event?.time || "9:00 AM",
|
|
||||||
duration: event?.duration || "1 hour",
|
|
||||||
type: event?.type || "meeting",
|
|
||||||
location: event?.location || "",
|
|
||||||
description: event?.description || "",
|
|
||||||
attendees: event?.attendees || [],
|
|
||||||
allDay: false,
|
|
||||||
reminder: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const [showCalendar, setShowCalendar] = useState(false)
|
|
||||||
const [newAttendee, setNewAttendee] = useState("")
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const eventData: Partial<CalendarEvent> = {
|
|
||||||
...formData,
|
|
||||||
id: event?.id,
|
|
||||||
color: eventTypes.find(t => t.value === formData.type)?.color || "bg-blue-500"
|
|
||||||
}
|
|
||||||
onSave(eventData)
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (event?.id && onDelete) {
|
|
||||||
onDelete(event.id)
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addAttendee = () => {
|
|
||||||
if (newAttendee.trim() && !formData.attendees.includes(newAttendee.trim())) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
attendees: [...prev.attendees, newAttendee.trim()]
|
|
||||||
}))
|
|
||||||
setNewAttendee("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeAttendee = (attendee: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
attendees: prev.attendees.filter(a => a !== attendee)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedEventType = eventTypes.find(t => t.value === formData.type)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<div className={cn("w-3 h-3 rounded-full", selectedEventType?.color)} />
|
|
||||||
{event ? "Edit Event" : "Create New Event"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{event ? "Make changes to this event" : "Add a new event to your calendar"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
{/* Event Title */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title" className="flex items-center gap-2">
|
|
||||||
<Type className="w-4 h-4" />
|
|
||||||
Event Title
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
placeholder="Enter event title..."
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
|
||||||
className="text-lg font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Event Type */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Tag className="w-4 h-4" />
|
|
||||||
Event Type
|
|
||||||
</Label>
|
|
||||||
<Select value={formData.type} onValueChange={(value) => setFormData(prev => ({ ...prev, type: value as CalendarEvent["type"] }))}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{eventTypes.map(type => (
|
|
||||||
<SelectItem key={type.value} value={type.value}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={cn("w-3 h-3 rounded-full", type.color)} />
|
|
||||||
{type.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date and Time */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
Date
|
|
||||||
</Label>
|
|
||||||
<Popover open={showCalendar} onOpenChange={setShowCalendar}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
|
||||||
{format(formData.date, "PPP")}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={formData.date}
|
|
||||||
onSelect={(date) => {
|
|
||||||
if (date) {
|
|
||||||
setFormData(prev => ({ ...prev, date }))
|
|
||||||
setShowCalendar(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
Time
|
|
||||||
</Label>
|
|
||||||
<Select value={formData.time} onValueChange={(value) => setFormData(prev => ({ ...prev, time: value }))}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{timeSlots.map(time => (
|
|
||||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration and All Day */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Duration</Label>
|
|
||||||
<Select value={formData.duration} onValueChange={(value) => setFormData(prev => ({ ...prev, duration: value }))}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{durationOptions.map(duration => (
|
|
||||||
<SelectItem key={duration} value={duration}>{duration}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Options</Label>
|
|
||||||
<div className="flex items-center space-x-4 h-10">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="all-day"
|
|
||||||
checked={formData.allDay}
|
|
||||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allDay: checked }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="all-day" className="text-sm">All day</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="reminder"
|
|
||||||
checked={formData.reminder}
|
|
||||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, reminder: checked }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="reminder" className="text-sm">Reminder</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="location" className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
Location
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="location"
|
|
||||||
placeholder="Add location..."
|
|
||||||
value={formData.location}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, location: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attendees */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
Attendees
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Add attendee..."
|
|
||||||
value={newAttendee}
|
|
||||||
onChange={(e) => setNewAttendee(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === "Enter" && addAttendee()}
|
|
||||||
/>
|
|
||||||
<Button onClick={addAttendee} variant="outline" className="cursor-pointer">Add</Button>
|
|
||||||
</div>
|
|
||||||
{formData.attendees.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{formData.attendees.map((attendee, index) => (
|
|
||||||
<Badge key={index} variant="secondary" className="flex items-center gap-2 px-2 py-1">
|
|
||||||
<Avatar className="w-5 h-5">
|
|
||||||
<AvatarFallback className="text-[10px] font-medium">
|
|
||||||
{attendee.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="text-sm">{attendee}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => removeAttendee(attendee)}
|
|
||||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="Add description..."
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 pt-6">
|
|
||||||
<Button onClick={handleSave} className="flex-1 cursor-pointer">
|
|
||||||
{event ? "Update Event" : "Create Event"}
|
|
||||||
</Button>
|
|
||||||
{event && onDelete && (
|
|
||||||
<Button onClick={handleDelete} variant="destructive" className="cursor-pointer">
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => onOpenChange(false)} variant="outline" className="cursor-pointer">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Clock,
|
|
||||||
Users,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
Download,
|
|
||||||
Share,
|
|
||||||
Bell
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
interface QuickActionsProps {
|
|
||||||
onNewEvent?: () => void
|
|
||||||
onNewMeeting?: () => void
|
|
||||||
onNewReminder?: () => void
|
|
||||||
onSettings?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuickActions({
|
|
||||||
onNewEvent,
|
|
||||||
onNewMeeting,
|
|
||||||
onNewReminder,
|
|
||||||
onSettings
|
|
||||||
}: QuickActionsProps) {
|
|
||||||
const quickStats = [
|
|
||||||
{ label: "Today's Events", value: "3", color: "bg-blue-500" },
|
|
||||||
{ label: "This Week", value: "12", color: "bg-green-500" },
|
|
||||||
{ label: "Pending", value: "2", color: "bg-orange-500" }
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{quickStats.map((stat, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${stat.color}`} />
|
|
||||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">{stat.value}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
onClick={onNewEvent}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Event
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
onClick={onNewMeeting}
|
|
||||||
>
|
|
||||||
<Users className="w-4 h-4 mr-2" />
|
|
||||||
Schedule Meeting
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
onClick={onNewReminder}
|
|
||||||
>
|
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
|
||||||
Set Reminder
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Separator className="my-3" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
>
|
|
||||||
<Share className="w-4 h-4 mr-2" />
|
|
||||||
Share Calendar
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start cursor-pointer"
|
|
||||||
onClick={onSettings}
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
Next Up
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">Team Standup</p>
|
|
||||||
<p className="text-xs text-muted-foreground">9:00 AM • Conference Room A</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-2 h-2 bg-purple-500 rounded-full mt-2" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">Design Review</p>
|
|
||||||
<p className="text-xs text-muted-foreground">2:00 PM • Virtual</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type EventRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allDay: boolean;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Customer = { id: string; name: string };
|
||||||
|
|
||||||
|
export const COLOR_PRESETS = [
|
||||||
|
{ value: "blue", label: "Mavi", classes: "bg-blue-500" },
|
||||||
|
{ value: "green", label: "Yeşil", classes: "bg-emerald-500" },
|
||||||
|
{ value: "amber", label: "Amber", classes: "bg-amber-500" },
|
||||||
|
{ value: "red", label: "Kırmızı", classes: "bg-red-500" },
|
||||||
|
{ value: "violet", label: "Mor", classes: "bg-violet-500" },
|
||||||
|
{ value: "slate", label: "Gri", classes: "bg-slate-500" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const COLOR_BG: Record<string, string> = {
|
||||||
|
blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
||||||
|
green: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
||||||
|
amber: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
|
||||||
|
red: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
|
||||||
|
violet: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
||||||
|
slate: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
|
||||||
|
"": "bg-primary/10 text-primary border-primary/20",
|
||||||
|
};
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { type CalendarEvent, type Calendar } from "./types"
|
|
||||||
|
|
||||||
// Import JSON data
|
|
||||||
import eventsData from "./data/events.json"
|
|
||||||
import eventDatesData from "./data/event-dates.json"
|
|
||||||
import calendarsData from "./data/calendars.json"
|
|
||||||
|
|
||||||
// Convert JSON events to CalendarEvent objects with proper Date objects
|
|
||||||
// Always use current month and year, but preserve day and time from JSON
|
|
||||||
export const events: CalendarEvent[] = eventsData.map(event => {
|
|
||||||
const now = new Date()
|
|
||||||
const currentYear = now.getFullYear()
|
|
||||||
const currentMonth = now.getMonth() // 0-based month
|
|
||||||
|
|
||||||
// Parse the day from the date string (format: "11T09:00:00.000Z")
|
|
||||||
const dayAndTime = event.date.split('T')
|
|
||||||
const day = parseInt(dayAndTime[0])
|
|
||||||
const timeStr = dayAndTime[1] // "09:00:00.000Z"
|
|
||||||
|
|
||||||
// Parse hours and minutes from time string
|
|
||||||
const timeParts = timeStr.split(':')
|
|
||||||
const hours = parseInt(timeParts[0])
|
|
||||||
const minutes = parseInt(timeParts[1])
|
|
||||||
|
|
||||||
// Create date with current year/month but original day and time
|
|
||||||
const eventDate = new Date(currentYear, currentMonth, day, hours, minutes)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
date: eventDate,
|
|
||||||
type: event.type as "meeting" | "event" | "personal" | "task" | "reminder"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert event dates for calendar picker - also use current month/year
|
|
||||||
export const eventDates = eventDatesData.map(item => {
|
|
||||||
const now = new Date()
|
|
||||||
const currentYear = now.getFullYear()
|
|
||||||
const currentMonth = now.getMonth()
|
|
||||||
|
|
||||||
// Parse day from date string
|
|
||||||
const day = parseInt(item.date.split('T')[0])
|
|
||||||
const eventDate = new Date(currentYear, currentMonth, day)
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: eventDate,
|
|
||||||
count: item.count
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calendars data
|
|
||||||
export const calendars: Calendar[] = calendarsData as Calendar[]
|
|
||||||
|
|
||||||
// Export individual collections for convenience
|
|
||||||
export { eventsData, eventDatesData, calendarsData }
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "personal",
|
|
||||||
"name": "Personal",
|
|
||||||
"color": "bg-blue-500",
|
|
||||||
"visible": true,
|
|
||||||
"type": "personal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "work",
|
|
||||||
"name": "Work",
|
|
||||||
"color": "bg-green-500",
|
|
||||||
"visible": true,
|
|
||||||
"type": "work"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "shared",
|
|
||||||
"name": "Team Calendar",
|
|
||||||
"color": "bg-purple-500",
|
|
||||||
"visible": true,
|
|
||||||
"type": "shared"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "meetings",
|
|
||||||
"name": "Meetings",
|
|
||||||
"color": "bg-orange-500",
|
|
||||||
"visible": true,
|
|
||||||
"type": "work"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "events",
|
|
||||||
"name": "Events",
|
|
||||||
"color": "bg-pink-500",
|
|
||||||
"visible": true,
|
|
||||||
"type": "shared"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"date": "11T00:00:00.000Z",
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "15T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "18T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "20T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "22T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "25T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "27T00:00:00.000Z",
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "Team Standup",
|
|
||||||
"date": "11T09:00:00.000Z",
|
|
||||||
"time": "9:00 AM",
|
|
||||||
"duration": "30 min",
|
|
||||||
"type": "meeting",
|
|
||||||
"attendees": ["JD", "SM", "AR"],
|
|
||||||
"location": "Conference Room A",
|
|
||||||
"color": "bg-blue-500",
|
|
||||||
"description": "Daily team standup meeting to discuss progress and blockers"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"title": "Design Review",
|
|
||||||
"date": "11T14:00:00.000Z",
|
|
||||||
"time": "2:00 PM",
|
|
||||||
"duration": "1 hour",
|
|
||||||
"type": "meeting",
|
|
||||||
"attendees": ["ER", "LC"],
|
|
||||||
"location": "Virtual",
|
|
||||||
"color": "bg-purple-500",
|
|
||||||
"description": "Review new UI designs and provide feedback"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"title": "Product Launch",
|
|
||||||
"date": "15T10:00:00.000Z",
|
|
||||||
"time": "10:00 AM",
|
|
||||||
"duration": "2 hours",
|
|
||||||
"type": "event",
|
|
||||||
"attendees": ["TL", "ST"],
|
|
||||||
"location": "Main Hall",
|
|
||||||
"color": "bg-green-500",
|
|
||||||
"description": "Official product launch event with stakeholders"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"title": "Client Presentation",
|
|
||||||
"date": "18T15:00:00.000Z",
|
|
||||||
"time": "3:00 PM",
|
|
||||||
"duration": "1 hour",
|
|
||||||
"type": "meeting",
|
|
||||||
"attendees": ["AT", "SM"],
|
|
||||||
"location": "Client Office",
|
|
||||||
"color": "bg-orange-500",
|
|
||||||
"description": "Present project progress to client stakeholders"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"title": "Birthday Party 🎉",
|
|
||||||
"date": "20T19:00:00.000Z",
|
|
||||||
"time": "7:00 PM",
|
|
||||||
"duration": "3 hours",
|
|
||||||
"type": "personal",
|
|
||||||
"attendees": ["PB", "VB"],
|
|
||||||
"location": "Home",
|
|
||||||
"color": "bg-pink-500",
|
|
||||||
"description": "Birthday celebration with friends and family"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
import { Calendar } from "./components/calendar"
|
import type { Metadata } from "next";
|
||||||
import { events, eventDates } from "./data"
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { listCalendarEvents } from "@/lib/appwrite/calendar-queries";
|
||||||
|
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { CalendarClient } from "./components/calendar-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "İşletmem — Takvim",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CalendarPage() {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, customers] = await Promise.all([
|
||||||
|
listCalendarEvents(ctx.tenantId),
|
||||||
|
listCustomers(ctx.tenantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
|
||||||
|
|
||||||
export default function CalendarPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6">
|
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||||
<Calendar events={events} eventDates={eventDates} />
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Takvim</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Toplantılar, randevular ve önemli tarihler.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
<CalendarClient
|
||||||
|
events={events.map((e) => ({
|
||||||
|
id: e.$id,
|
||||||
|
title: e.title,
|
||||||
|
description: e.description ?? "",
|
||||||
|
start: e.start,
|
||||||
|
end: e.end,
|
||||||
|
allDay: Boolean(e.allDay),
|
||||||
|
customerId: e.customerId ?? "",
|
||||||
|
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
|
||||||
|
color: e.color ?? "",
|
||||||
|
}))}
|
||||||
|
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
export interface CalendarEvent {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
date: Date
|
|
||||||
time: string
|
|
||||||
duration: string
|
|
||||||
type: "meeting" | "event" | "personal" | "task" | "reminder"
|
|
||||||
attendees: string[]
|
|
||||||
location: string
|
|
||||||
color: string
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Calendar {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
visible: boolean
|
|
||||||
type: "personal" | "work" | "shared"
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
|
||||||
import { type CalendarEvent } from "./types"
|
|
||||||
|
|
||||||
export interface UseCalendarState {
|
|
||||||
selectedDate: Date
|
|
||||||
showEventForm: boolean
|
|
||||||
editingEvent: CalendarEvent | null
|
|
||||||
showCalendarSheet: boolean
|
|
||||||
events: CalendarEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseCalendarActions {
|
|
||||||
setSelectedDate: (date: Date) => void
|
|
||||||
setShowEventForm: (show: boolean) => void
|
|
||||||
setEditingEvent: (event: CalendarEvent | null) => void
|
|
||||||
setShowCalendarSheet: (show: boolean) => void
|
|
||||||
handleDateSelect: (date: Date) => void
|
|
||||||
handleNewEvent: () => void
|
|
||||||
handleNewCalendar: () => void
|
|
||||||
handleSaveEvent: (eventData: Partial<CalendarEvent>) => void
|
|
||||||
handleDeleteEvent: (eventId: number) => void
|
|
||||||
handleEditEvent: (event: CalendarEvent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseCalendarReturn extends UseCalendarState, UseCalendarActions {}
|
|
||||||
|
|
||||||
export function useCalendar(initialEvents: CalendarEvent[] = []): UseCalendarReturn {
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
|
|
||||||
const [showEventForm, setShowEventForm] = useState(false)
|
|
||||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
|
||||||
const [showCalendarSheet, setShowCalendarSheet] = useState(false)
|
|
||||||
const [events] = useState<CalendarEvent[]>(initialEvents)
|
|
||||||
|
|
||||||
const handleDateSelect = useCallback((date: Date) => {
|
|
||||||
setSelectedDate(date)
|
|
||||||
// Auto-close mobile sheet when date is selected
|
|
||||||
setShowCalendarSheet(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleNewEvent = useCallback(() => {
|
|
||||||
setEditingEvent(null)
|
|
||||||
setShowEventForm(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleNewCalendar = useCallback(() => {
|
|
||||||
console.log("Creating new calendar")
|
|
||||||
// In a real app, this would open a new calendar form
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSaveEvent = useCallback((eventData: Partial<CalendarEvent>) => {
|
|
||||||
console.log("Saving event:", eventData)
|
|
||||||
// In a real app, this would save to a backend
|
|
||||||
setShowEventForm(false)
|
|
||||||
setEditingEvent(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDeleteEvent = useCallback((eventId: number) => {
|
|
||||||
console.log("Deleting event:", eventId)
|
|
||||||
// In a real app, this would delete from backend
|
|
||||||
setShowEventForm(false)
|
|
||||||
setEditingEvent(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleEditEvent = useCallback((event: CalendarEvent) => {
|
|
||||||
setEditingEvent(event)
|
|
||||||
setShowEventForm(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
selectedDate,
|
|
||||||
showEventForm,
|
|
||||||
editingEvent,
|
|
||||||
showCalendarSheet,
|
|
||||||
events,
|
|
||||||
// Actions
|
|
||||||
setSelectedDate,
|
|
||||||
setShowEventForm,
|
|
||||||
setEditingEvent,
|
|
||||||
setShowCalendarSheet,
|
|
||||||
handleDateSelect,
|
|
||||||
handleNewEvent,
|
|
||||||
handleNewCalendar,
|
|
||||||
handleSaveEvent,
|
|
||||||
handleDeleteEvent,
|
|
||||||
handleEditEvent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import { DATABASE_ID, TABLES, type CalendarEvent } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireTenant } from "./tenant-guard";
|
||||||
|
import type { CalendarActionState } from "./calendar-types";
|
||||||
|
import { calendarEventSchema } from "@/lib/validation/calendar";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown): string {
|
||||||
|
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
|
||||||
|
return "Bağlantı hatası. Tekrar deneyin.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of err.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamRowPermissions(tenantId: string) {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(tenantId)),
|
||||||
|
Permission.update(Role.team(tenantId)),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "admin")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFormFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
title: String(formData.get("title") ?? "").trim(),
|
||||||
|
description: String(formData.get("description") ?? "").trim(),
|
||||||
|
start: String(formData.get("start") ?? ""),
|
||||||
|
end: String(formData.get("end") ?? ""),
|
||||||
|
allDay: formData.get("allDay") ?? false,
|
||||||
|
customerId: String(formData.get("customerId") ?? ""),
|
||||||
|
color: String(formData.get("color") ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(v: string, allDay: boolean | undefined): string {
|
||||||
|
if (!v) return v;
|
||||||
|
// datetime-local => "YYYY-MM-DDTHH:mm"; date => "YYYY-MM-DD"
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||||
|
return `${v}T00:00:00.000+00:00`;
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v)) {
|
||||||
|
return new Date(v).toISOString();
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) {
|
||||||
|
return new Date(v).toISOString();
|
||||||
|
}
|
||||||
|
// fallback for already-iso
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCalendarEventAction(
|
||||||
|
_prev: CalendarActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<CalendarActionState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = calendarEventSchema.safeParse(pickFormFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const data = {
|
||||||
|
...parsed.data,
|
||||||
|
start: toIso(parsed.data.start, parsed.data.allDay),
|
||||||
|
end: toIso(parsed.data.end, parsed.data.allDay),
|
||||||
|
};
|
||||||
|
const row = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.calendarEvents,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
teamRowPermissions(ctx.tenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "calendar_event",
|
||||||
|
entityId: row.$id,
|
||||||
|
changes: data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCalendarEventAction(
|
||||||
|
_prev: CalendarActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<CalendarActionState> {
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
if (!id) return { ok: false, error: "ID eksik." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = calendarEventSchema.safeParse(pickFormFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const existing = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.calendarEvents,
|
||||||
|
id,
|
||||||
|
)) as unknown as CalendarEvent;
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...parsed.data,
|
||||||
|
start: toIso(parsed.data.start, parsed.data.allDay),
|
||||||
|
end: toIso(parsed.data.end, parsed.data.allDay),
|
||||||
|
};
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.calendarEvents, id, data);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "calendar_event",
|
||||||
|
entityId: id,
|
||||||
|
changes: data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCalendarEventAction(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<CalendarActionState> {
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
if (!id) return { ok: false, error: "ID eksik." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const existing = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.calendarEvents,
|
||||||
|
id,
|
||||||
|
)) as unknown as CalendarEvent;
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.calendarEvents, id);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "calendar_event",
|
||||||
|
entityId: id,
|
||||||
|
changes: { title: existing.title },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { DATABASE_ID, TABLES, type CalendarEvent } from "./schema";
|
||||||
|
|
||||||
|
export async function listCalendarEvents(
|
||||||
|
tenantId: string,
|
||||||
|
rangeStart?: string,
|
||||||
|
rangeEnd?: string,
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const queries = [Query.equal("tenantId", tenantId), Query.limit(1000)];
|
||||||
|
if (rangeStart) queries.push(Query.greaterThanEqual("start", rangeStart));
|
||||||
|
if (rangeEnd) queries.push(Query.lessThanEqual("start", rangeEnd));
|
||||||
|
queries.push(Query.orderAsc("start"));
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.calendarEvents,
|
||||||
|
queries,
|
||||||
|
});
|
||||||
|
return result.rows as unknown as CalendarEvent[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type CalendarActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialCalendarState: CalendarActionState = { ok: false };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const calendarEventSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1, "Başlık zorunlu.").max(255),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(2000)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
start: z.string().min(1, "Başlangıç zorunlu."),
|
||||||
|
end: z.string().min(1, "Bitiş zorunlu."),
|
||||||
|
allDay: z
|
||||||
|
.union([z.boolean(), z.literal("on"), z.literal(""), z.undefined()])
|
||||||
|
.transform((v) => v === true || v === "on")
|
||||||
|
.optional(),
|
||||||
|
customerId: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||||
|
color: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||||
|
})
|
||||||
|
.refine((d) => new Date(d.end).getTime() >= new Date(d.start).getTime(), {
|
||||||
|
message: "Bitiş tarihi başlangıçtan önce olamaz.",
|
||||||
|
path: ["end"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarEventInput = z.infer<typeof calendarEventSchema>;
|
||||||
Reference in New Issue
Block a user