feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy
- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { FreeBusyView } from "./free-busy-view";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -351,6 +352,7 @@ export function EventModal({
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
@@ -1074,6 +1076,44 @@ export function EventModal({
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFreeBusy((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFreeBusy ? (
|
||||
<EyeOff className="w-3.5 h-3.5 me-1" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
|
||||
</Button>
|
||||
{showFreeBusy && (
|
||||
<div className="mt-3">
|
||||
<FreeBusyView
|
||||
participants={attendees}
|
||||
startDate={(() => {
|
||||
const d = new Date(`${startDate}T${startTime}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
endDate={(() => {
|
||||
const d = new Date(`${endDate}T${endTime}:00`);
|
||||
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
|
||||
})()}
|
||||
onTimeSelect={(start, end) => {
|
||||
setStartDate(formatDateInput(start));
|
||||
setStartTime(formatTimeInput(start));
|
||||
setEndDate(formatDateInput(end));
|
||||
setEndTime(formatTimeInput(end));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
||||
|
||||
export interface FreeBusyViewProps {
|
||||
participants: { name?: string; email: string }[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onTimeSelect?: (start: Date, end: Date) => void;
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
const WORK_START_HOUR = 8;
|
||||
const WORK_END_HOUR = 18;
|
||||
|
||||
const statusColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
||||
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
||||
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
||||
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
||||
unknown: "bg-muted border-muted-foreground/20",
|
||||
};
|
||||
|
||||
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
||||
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
||||
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
||||
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
||||
unknown: "hover:bg-muted-foreground/20",
|
||||
};
|
||||
|
||||
function clampToSlot(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
||||
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
||||
let cursor = clampToSlot(start);
|
||||
while (cursor < end) {
|
||||
const hourEnd = new Date(cursor);
|
||||
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
||||
const hourSlots: FreeBusySlot[] = [];
|
||||
let slotCursor = new Date(cursor);
|
||||
while (slotCursor < hourEnd && slotCursor < end) {
|
||||
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
||||
hourSlots.push({
|
||||
start: new Date(slotCursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
slotCursor = slotEnd;
|
||||
}
|
||||
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
||||
cursor = hourEnd;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function isWorkingHour(hour: number): boolean {
|
||||
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
||||
}
|
||||
|
||||
export function FreeBusyView({
|
||||
participants,
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
participant: string;
|
||||
slotIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
||||
const totalHalfHourSlots = useMemo(() => {
|
||||
let c = 0;
|
||||
for (const h of hourSlots) c += h.slots.length;
|
||||
return c;
|
||||
}, [hourSlots]);
|
||||
|
||||
const now = new Date();
|
||||
const showNowLine =
|
||||
now >= startDate && now <= endDate;
|
||||
const nowPositionPercent = showNowLine
|
||||
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, participants, startDate, endDate]);
|
||||
|
||||
const handleSlotClick = useCallback(
|
||||
(slot: FreeBusySlot) => {
|
||||
if (slot.status === "free" && onTimeSelect) {
|
||||
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
||||
}
|
||||
},
|
||||
[onTimeSelect]
|
||||
);
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("freeBusy.no_participants")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("freeBusy.timezone")}: {timezone}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("freeBusy.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
||||
{t("participants.title")}
|
||||
</th>
|
||||
{hourSlots.map((hour, i) => (
|
||||
<th
|
||||
key={i}
|
||||
colSpan={hour.slots.length}
|
||||
className={cn(
|
||||
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
||||
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
||||
? "bg-muted/50"
|
||||
: "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{hour.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p) => {
|
||||
const key = p.email.toLowerCase();
|
||||
const slots = freeBusyData?.get(key);
|
||||
return (
|
||||
<tr key={key} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{p.name || p.email}
|
||||
</div>
|
||||
{p.name && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const slot = slots?.[globalSlotIndex];
|
||||
const status = slot?.status ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
const isHovered =
|
||||
hoveredSlot?.participant === key &&
|
||||
hoveredSlot?.slotIndex === globalSlotIndex;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && statusHoverColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isHovered && "ring-1 ring-inset ring-primary/50",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
slotIndex: globalSlotIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredSlot(null)}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNowLine && nowPositionPercent !== null && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
||||
{t("freeBusy.free")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
||||
{t("freeBusy.busy")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
||||
{t("freeBusy.tentative")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
||||
{t("freeBusy.unavailable")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
||||
{t("freeBusy.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user