feat: add task management features to calendar
This commit is contained in:
@@ -4,10 +4,11 @@ import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
|
||||
@@ -22,6 +23,8 @@ interface CalendarDayViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -38,6 +41,8 @@ export function CalendarDayView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -68,6 +73,16 @@ export function CalendarDayView({
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events, selectedDate]);
|
||||
|
||||
const dayTasks = useMemo(() => {
|
||||
if (!tasks?.length) return [];
|
||||
return tasks.filter(task => {
|
||||
if (!task.due) return false;
|
||||
try {
|
||||
return isSameDay(parseISO(task.due), selectedDate);
|
||||
} catch { return false; }
|
||||
});
|
||||
}, [tasks, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
@@ -125,25 +140,63 @@ export function CalendarDayView({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{allDayEvents.length > 0 && (
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allDayEvents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dayTasks.length > 0 && (
|
||||
<>
|
||||
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.map((task) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||
isCompleted
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: "border-muted-foreground/40 hover:border-primary"
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title || t("tasks.no_title")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { Globe, ListTodo, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
@@ -35,6 +36,15 @@ export function CalendarSidebarPanel({
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
|
||||
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
|
||||
const overdueTaskCount = useMemo(() => {
|
||||
const now = new Date();
|
||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||
}, [tasks]);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
@@ -209,6 +219,21 @@ export function CalendarSidebarPanel({
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
<button
|
||||
onClick={() => setViewMode('tasks')}
|
||||
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{t('tasks.label')}</span>
|
||||
{pendingTaskCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||
)}
|
||||
{overdueTaskCount > 0 && (
|
||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
|
||||
calendars?: Calendar[];
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
enableCalendarTasks?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -42,10 +43,13 @@ export function CalendarToolbar({
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
enableCalendarTasks,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
||||
const views: CalendarViewMode[] = enableCalendarTasks
|
||||
? ["month", "week", "day", "agenda", "tasks"]
|
||||
: ["month", "week", "day", "agenda"];
|
||||
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -86,6 +90,8 @@ export function CalendarToolbar({
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
case "tasks":
|
||||
return t("views.tasks");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
|
||||
@@ -26,6 +27,8 @@ interface CalendarWeekViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -44,6 +47,8 @@ export function CalendarWeekView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -96,9 +101,36 @@ export function CalendarWeekView({
|
||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
}, [allDaySegments]);
|
||||
|
||||
// Tasks grouped by day for the week
|
||||
const tasksByDay = useMemo(() => {
|
||||
if (!tasks?.length) return new Map<string, CalendarTask[]>();
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
for (const task of tasks) {
|
||||
if (!task.due) continue;
|
||||
try {
|
||||
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||
const existing = map.get(key) || [];
|
||||
existing.push(task);
|
||||
map.set(key, existing);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
// Max tasks on any single day in this week
|
||||
const taskRowCount = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const day of weekDays) {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const count = tasksByDay.get(key)?.length ?? 0;
|
||||
if (count > max) max = count;
|
||||
}
|
||||
return max;
|
||||
}, [tasksByDay, weekDays]);
|
||||
|
||||
const hasAllDay = useMemo(() => {
|
||||
return allDaySegments.length > 0;
|
||||
}, [allDaySegments]);
|
||||
return allDaySegments.length > 0 || taskRowCount > 0;
|
||||
}, [allDaySegments, taskRowCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -151,13 +183,13 @@ export function CalendarWeekView({
|
||||
<div className="flex border-b border-border">
|
||||
<div
|
||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{t("events.all_day")}
|
||||
</div>
|
||||
<div
|
||||
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{weekDays.map((day) => (
|
||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||
@@ -191,6 +223,49 @@ export function CalendarWeekView({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task chips in all-day area */}
|
||||
{taskRowCount > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
|
||||
{weekDays.map((day, dayIndex) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayTasks = tasksByDay.get(key) || [];
|
||||
return dayTasks.map((task, taskIndex) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||
top: taskIndex * 24,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
|
||||
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
|
||||
onClick={() => onToggleTaskComplete?.(task)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||
isCompleted ? "bg-green-500 border-green-500" : "border-current"
|
||||
)}>
|
||||
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||
</span>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
||||
import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react";
|
||||
import { Check, Circle, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
@@ -18,6 +18,7 @@ interface TaskListViewProps {
|
||||
onSelectTask: (task: CalendarTask) => void;
|
||||
onToggleComplete: (task: CalendarTask) => void;
|
||||
selectedTaskId?: string | null;
|
||||
onQuickCreate?: (title: string) => void;
|
||||
}
|
||||
|
||||
function getTaskPriorityIcon(priority: number) {
|
||||
@@ -69,9 +70,11 @@ export function TaskListView({
|
||||
onSelectTask,
|
||||
onToggleComplete,
|
||||
selectedTaskId,
|
||||
onQuickCreate,
|
||||
}: TaskListViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks.filter(task => {
|
||||
@@ -128,15 +131,57 @@ export function TaskListView({
|
||||
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||
<div className="flex flex-col flex-1">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-border">
|
||||
{filteredTasks.map(task => {
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
calendars: Calendar[];
|
||||
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
|
||||
onDelete?: (id: string) => void;
|
||||
onClose: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
type PriorityLevel = "none" | "high" | "medium" | "low";
|
||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||
|
||||
function priorityToLevel(p: number): PriorityLevel {
|
||||
if (p >= 1 && p <= 4) return "high";
|
||||
if (p === 5) return "medium";
|
||||
if (p >= 6 && p <= 9) return "low";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function levelToPriority(l: PriorityLevel): number {
|
||||
switch (l) {
|
||||
case "high": return 1;
|
||||
case "medium": return 5;
|
||||
case "low": return 9;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function TaskModal({
|
||||
task,
|
||||
calendars,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
isMobile,
|
||||
}: TaskModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!task;
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState(task?.title ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : "");
|
||||
const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : "");
|
||||
const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false);
|
||||
const [priority, setPriority] = useState<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||
const [progress, setProgress] = useState<CalendarTask["progress"]>(task?.progress ?? "needs-action");
|
||||
const [calendarId, setCalendarId] = useState(() => {
|
||||
if (task) {
|
||||
const ids = Object.keys(task.calendarIds);
|
||||
return ids[0] ?? defaultCalendarId;
|
||||
}
|
||||
return defaultCalendarId;
|
||||
});
|
||||
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
|
||||
if (!task?.alerts) return "none";
|
||||
const first = Object.values(task.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none";
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return "at_time";
|
||||
const m = offset.match(/-?PT?(\d+)M$/);
|
||||
if (m) return m[1] as AlertOption;
|
||||
const h = offset.match(/-?PT?(\d+)H$/);
|
||||
if (h) return String(parseInt(h[1]) * 60) as AlertOption;
|
||||
const d = offset.match(/-?P(\d+)D/);
|
||||
if (d) return String(parseInt(d[1]) * 1440) as AlertOption;
|
||||
return "none";
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
let due: string | null = null;
|
||||
let showWithoutTime = true;
|
||||
if (dueDate) {
|
||||
if (showTime && dueTime) {
|
||||
due = `${dueDate}T${dueTime}:00`;
|
||||
showWithoutTime = false;
|
||||
} else {
|
||||
due = `${dueDate}T00:00:00`;
|
||||
showWithoutTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
let alerts: Record<string, CalendarEventAlert> | null = null;
|
||||
if (alertOption !== "none") {
|
||||
const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`;
|
||||
alerts = {
|
||||
"default-alert": {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data: Partial<CalendarTask> = {
|
||||
"@type": "Task",
|
||||
title: title.trim(),
|
||||
description: description.trim() || "",
|
||||
due,
|
||||
showWithoutTime,
|
||||
priority: levelToPriority(priority),
|
||||
progress,
|
||||
calendarIds: { [calendarId]: true },
|
||||
alerts,
|
||||
};
|
||||
|
||||
if (isEdit && task) {
|
||||
data.id = task.id;
|
||||
}
|
||||
|
||||
await onSave(data);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}, [onClose, handleSave]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
ref={titleRef}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("tasks.title_placeholder")}
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("tasks.description_placeholder")}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
|
||||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{t("tasks.due_date")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{dueDate && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTime}
|
||||
onChange={(e) => setShowTime(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t("tasks.include_time")}
|
||||
</label>
|
||||
)}
|
||||
{showTime && (
|
||||
<input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
{t("tasks.priority")}
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="none">{t("tasks.priority_none")}</option>
|
||||
<option value="high">{t("tasks.priority_high")}</option>
|
||||
<option value="medium">{t("tasks.priority_medium")}</option>
|
||||
<option value="low">{t("tasks.priority_low")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.progress")}
|
||||
</label>
|
||||
<select
|
||||
value={progress}
|
||||
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
{writableCalendars.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.calendar")}
|
||||
</label>
|
||||
<select
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
{writableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{t("tasks.alert")}
|
||||
</label>
|
||||
<select
|
||||
value={alertOption}
|
||||
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="none">{t("tasks.alert_none")}</option>
|
||||
<option value="at_time">{t("tasks.alert_at_time")}</option>
|
||||
<option value="5">{t("tasks.alert_5min")}</option>
|
||||
<option value="15">{t("tasks.alert_15min")}</option>
|
||||
<option value="30">{t("tasks.alert_30min")}</option>
|
||||
<option value="60">{t("tasks.alert_1hr")}</option>
|
||||
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<div>
|
||||
{isEdit && onDelete && task && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(task.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t("tasks.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("tasks.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
|
||||
{t("tasks.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
|
||||
interface TaskToolbarProps {
|
||||
filter: TaskViewFilter;
|
||||
showCompleted: boolean;
|
||||
onFilterChange: (filter: TaskViewFilter) => void;
|
||||
onShowCompletedChange: (show: boolean) => void;
|
||||
onCreateTask: () => void;
|
||||
}
|
||||
|
||||
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||
|
||||
export function TaskToolbar({
|
||||
filter,
|
||||
showCompleted,
|
||||
onFilterChange,
|
||||
onShowCompletedChange,
|
||||
onCreateTask,
|
||||
}: TaskToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||
<div className="flex border border-border rounded-md overflow-hidden">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onFilterChange(f)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
f === filter
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(`tasks.filter_${f}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => onShowCompletedChange(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{t("tasks.show_completed")}
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button size="sm" onClick={onCreateTask}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("tasks.create")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export function CalendarSettings() {
|
||||
calendarNotificationsEnabled,
|
||||
calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled,
|
||||
enableCalendarTasks,
|
||||
showTasksOnCalendar,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -79,6 +81,28 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('enable_tasks')}
|
||||
description={t('enable_tasks_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableCalendarTasks}
|
||||
onChange={(checked) => updateSetting('enableCalendarTasks', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{enableCalendarTasks && (
|
||||
<SettingItem
|
||||
label={t('show_tasks_on_calendar')}
|
||||
description={t('show_tasks_on_calendar_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCalendar}
|
||||
onChange={(checked) => updateSetting('showTasksOnCalendar', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('notifications_enabled')}
|
||||
description={t('notifications_enabled_desc')}
|
||||
|
||||
Reference in New Issue
Block a user