Phase 5: Shared calendar polish — scoped polling, admin stats, dual panel fix, edge case handling
- Scope shared calendar polling to CalendarPage only (other consumers no longer poll) - Add admin sharing stats card (owned/member/invites sent/received) in UserDetailSection - Fix dual EventDetailPanel mount via JS media query breakpoint (replaces CSS hidden) - Auto-close panel + toast when shared calendar is removed while viewing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f45b7a2115
commit
14fc085009
@ -1,10 +1,10 @@
|
||||
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserRole } from '@/types';
|
||||
@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
||||
const updateRole = useUpdateRole();
|
||||
const { data: sharingStats } = useAdminSharingStats(userId);
|
||||
|
||||
const handleRoleChange = async (newRole: UserRole) => {
|
||||
if (!user || newRole === user.role) return;
|
||||
@ -218,6 +219,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sharing Stats */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Share2 className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-sm">Sharing</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
|
||||
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
|
||||
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
|
||||
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ export default function CalendarPage() {
|
||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { data: calendars = [], sharedData } = useCalendars();
|
||||
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
|
||||
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -99,6 +99,15 @@ export default function CalendarPage() {
|
||||
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
|
||||
// Track desktop breakpoint to prevent dual EventDetailPanel mount
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(min-width: 1024px)');
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Continuously resize calendar during panel open/close CSS transition
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
@ -150,6 +159,15 @@ export default function CalendarPage() {
|
||||
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
||||
const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false;
|
||||
|
||||
// Close panel if shared calendar was removed while viewing
|
||||
useEffect(() => {
|
||||
if (!selectedEvent || allCalendarIds.size === 0) return;
|
||||
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
|
||||
handlePanelClose();
|
||||
toast.info('This calendar is no longer available');
|
||||
}
|
||||
}, [allCalendarIds, selectedEvent]);
|
||||
|
||||
// Escape key closes detail panel
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
@ -497,29 +515,27 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
|
||||
{/* Detail panel (desktop) */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
{panelOpen && isDesktop && (
|
||||
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && (
|
||||
{panelOpen && !isDesktop && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -61,6 +61,24 @@ export function useAdminUserDetail(userId: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
interface SharingStats {
|
||||
shared_calendars_owned: number;
|
||||
calendars_member_of: number;
|
||||
pending_invites_sent: number;
|
||||
pending_invites_received: number;
|
||||
}
|
||||
|
||||
export function useAdminSharingStats(userId: number | null) {
|
||||
return useQuery<SharingStats>({
|
||||
queryKey: ['admin', 'users', userId, 'sharing-stats'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SharingStats>(`/admin/users/${userId}/sharing-stats`);
|
||||
return data;
|
||||
},
|
||||
enabled: userId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDashboard() {
|
||||
return useQuery<AdminDashboardData>({
|
||||
queryKey: ['admin', 'dashboard'],
|
||||
|
||||
@ -3,7 +3,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||
|
||||
export function useCalendars() {
|
||||
interface UseCalendarsOptions {
|
||||
pollingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) {
|
||||
const ownedQuery = useQuery({
|
||||
queryKey: ['calendars'],
|
||||
queryFn: async () => {
|
||||
@ -18,7 +22,7 @@ export function useCalendars() {
|
||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||
staleTime: 3_000,
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user