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:
kovakmedya
2026-04-30 06:01:42 +03:00
parent 671195fb7d
commit b4c1073d91
22 changed files with 885 additions and 1925 deletions
@@ -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",
};
-55
View File
@@ -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"
}
]
+50 -6
View File
@@ -1,10 +1,54 @@
import { Calendar } from "./components/calendar"
import { events, eventDates } from "./data"
import type { Metadata } from "next";
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 (
<div className="px-4 lg:px-6">
<Calendar events={events} eventDates={eventDates} />
<div className="flex-1 space-y-6 px-6 pt-0">
<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>
<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>
)
);
}
-20
View File
@@ -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,
}
}
+209
View File
@@ -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 };
}
+28
View File
@@ -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 [];
}
}
+7
View File
@@ -0,0 +1,7 @@
export type CalendarActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialCalendarState: CalendarActionState = { ok: false };
+26
View File
@@ -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>;