Files
kovakemlak-crm/src/components/activities/activity-calendar.tsx
T

321 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo } from "react";
import { CaretLeft, CaretRight, CheckCircle, PencilSimple, Clock, User, Buildings } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
const DAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
const MONTHS = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
];
const TYPE_COLORS: Record<string, string> = {
gorusme: "bg-blue-500",
teklif: "bg-amber-500",
ziyaret: "bg-emerald-500",
arama: "bg-purple-500",
not: "bg-gray-400",
};
const TYPE_BADGE_COLORS: Record<string, string> = {
gorusme: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
teklif: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
ziyaret: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
arama: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
not: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
};
function toDateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function activityDateKey(a: Activity): string | null {
if (!a.dueDate) return null;
const d = new Date(a.dueDate);
if (isNaN(d.getTime())) return null;
return toDateKey(d);
}
interface Props {
activities: Activity[];
customers: Customer[];
properties: Property[];
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
}
export function ActivityCalendar({ activities, customers, properties, onEdit, onComplete }: Props) {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [selectedKey, setSelectedKey] = useState<string | null>(toDateKey(today));
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
}
// Index activities by date key
const byDate = useMemo(() => {
const map: Record<string, Activity[]> = {};
for (const a of activities) {
const key = activityDateKey(a);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(a);
}
return map;
}, [activities]);
// Build calendar grid
const cells = useMemo(() => {
const firstDay = new Date(year, month, 1);
// Monday-based: 0=Mon … 6=Sun
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const totalCells = Math.ceil((startOffset + daysInMonth) / 7) * 7;
const result: Array<{ date: Date | null; key: string | null }> = [];
for (let i = 0; i < totalCells; i++) {
const dayNum = i - startOffset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
result.push({ date: null, key: null });
} else {
const d = new Date(year, month, dayNum);
result.push({ date: d, key: toDateKey(d) });
}
}
return result;
}, [year, month]);
const selectedActivities = selectedKey ? (byDate[selectedKey] ?? []) : [];
const selectedDate = selectedKey ? new Date(selectedKey + "T12:00:00") : null;
function customerName(id?: string | null) {
if (!id) return null;
return customers.find((c) => c.$id === id)?.name ?? null;
}
function propertyTitle(id?: string | null) {
if (!id) return null;
return properties.find((p) => p.$id === id)?.title ?? null;
}
const todayKey = toDateKey(today);
return (
<div className="flex flex-col lg:flex-row gap-4 min-h-0">
{/* === Calendar Grid === */}
<div className="flex-1 min-w-0">
{/* Month nav */}
<div className="flex items-center justify-between mb-3">
<Button variant="ghost" size="icon" onClick={prevMonth} className="size-8">
<CaretLeft className="size-4" />
</Button>
<span className="font-semibold text-base">
{MONTHS[month]} {year}
</span>
<Button variant="ghost" size="icon" onClick={nextMonth} className="size-8">
<CaretRight className="size-4" />
</Button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{DAYS.map((d) => (
<div key={d} className="text-center text-xs text-muted-foreground py-1 font-medium">
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
{cells.map((cell, i) => {
if (!cell.date || !cell.key) {
return <div key={i} className="bg-muted/40 min-h-[72px]" />;
}
const cellActivities = byDate[cell.key] ?? [];
const isToday = cell.key === todayKey;
const isSelected = cell.key === selectedKey;
const visible = cellActivities.slice(0, 3);
const overflow = cellActivities.length - 3;
return (
<button
key={cell.key}
type="button"
onClick={() => setSelectedKey(cell.key)}
className={`bg-background min-h-[72px] p-1.5 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isSelected ? "ring-2 ring-inset ring-primary" : ""
}`}
>
<span
className={`text-xs font-medium inline-flex size-5 items-center justify-center rounded-full mb-1 ${
isToday
? "bg-primary text-primary-foreground"
: "text-foreground"
}`}
>
{cell.date.getDate()}
</span>
<div className="flex flex-col gap-px">
{visible.map((a) => (
<div
key={a.$id}
className={`text-[10px] leading-4 rounded px-1 truncate text-white ${TYPE_COLORS[a.type] ?? "bg-gray-400"} ${
a.completedAt ? "opacity-50" : ""
}`}
>
{a.title}
</div>
))}
{overflow > 0 && (
<div className="text-[10px] text-muted-foreground pl-1">+{overflow} daha</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-3 text-xs text-muted-foreground">
{Object.entries(ACTIVITY_TYPE_LABELS).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className={`inline-block size-2 rounded-full ${TYPE_COLORS[key]}`} />
{label}
</div>
))}
</div>
</div>
{/* === Day Panel === */}
<div className="w-full lg:w-72 xl:w-80 shrink-0">
<div className="rounded-lg border h-full flex flex-col">
{/* Panel header */}
<div className="px-4 py-3 border-b">
{selectedDate ? (
<div>
<p className="font-semibold text-sm">
{selectedDate.toLocaleDateString("tr-TR", { weekday: "long", day: "numeric", month: "long" })}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{selectedActivities.length === 0
? "Bu gün için aktivite yok"
: `${selectedActivities.length} aktivite`}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">Gün seçin</p>
)}
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto">
{selectedActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
<Clock className="size-8 mb-2 opacity-30" />
<span>Aktivite bulunmuyor</span>
</div>
) : (
<div className="divide-y">
{selectedActivities.map((a) => (
<ActivityCard
key={a.$id}
activity={a}
customerName={customerName(a.customerId)}
propertyTitle={propertyTitle(a.propertyId)}
onEdit={onEdit}
onComplete={onComplete}
typeBadgeColor={TYPE_BADGE_COLORS[a.type] ?? TYPE_BADGE_COLORS.not}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
interface CardProps {
activity: Activity;
customerName: string | null;
propertyTitle: string | null;
typeBadgeColor: string;
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
}
function ActivityCard({ activity: a, customerName, propertyTitle, typeBadgeColor, onEdit, onComplete }: CardProps) {
return (
<div className={`px-4 py-3 hover:bg-muted/40 transition-colors ${a.completedAt ? "opacity-60" : ""}`}>
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className={`inline-flex text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadgeColor}`}>
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
</span>
{a.completedAt ? (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">Tamam</Badge>
) : (
<Badge className="text-[10px] h-4 px-1.5">Açık</Badge>
)}
</div>
<p className="text-sm font-medium leading-tight mb-1.5">{a.title}</p>
{a.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-1.5">{a.description}</p>
)}
<div className="flex flex-col gap-0.5 mb-2">
{customerName && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0" />
<span className="truncate">{customerName}</span>
</div>
)}
{propertyTitle && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Buildings className="size-3 shrink-0" />
<span className="truncate">{propertyTitle}</span>
</div>
)}
</div>
<div className="flex gap-1.5">
{!a.completedAt && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2 flex-1"
onClick={() => onComplete(a)}
>
<CheckCircle className="size-3 mr-1" />
Tamamla
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2 flex-1"
onClick={() => onEdit(a)}
>
<PencilSimple className="size-3 mr-1" />
Düzenle
</Button>
</div>
</div>
);
}