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 { toast } from 'sonner';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { getRelativeTime } from '@/lib/date-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { UserRole } from '@/types';
|
import type { UserRole } from '@/types';
|
||||||
@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
|||||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
||||||
const updateRole = useUpdateRole();
|
const updateRole = useUpdateRole();
|
||||||
|
const { data: sharingStats } = useAdminSharingStats(userId);
|
||||||
|
|
||||||
const handleRoleChange = async (newRole: UserRole) => {
|
const handleRoleChange = async (newRole: UserRole) => {
|
||||||
if (!user || newRole === user.role) return;
|
if (!user || newRole === user.role) return;
|
||||||
@ -218,6 +219,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export default function CalendarPage() {
|
|||||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||||
|
|
||||||
const { settings } = useSettings();
|
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 [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -99,6 +99,15 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const panelOpen = panelMode !== 'closed';
|
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
|
// Continuously resize calendar during panel open/close CSS transition
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let rafId: number;
|
let rafId: number;
|
||||||
@ -150,6 +159,15 @@ export default function CalendarPage() {
|
|||||||
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
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;
|
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
|
// Escape key closes detail panel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelOpen) return;
|
if (!panelOpen) return;
|
||||||
@ -497,29 +515,27 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail panel (desktop) */}
|
{/* Detail panel (desktop) */}
|
||||||
<div
|
{panelOpen && isDesktop && (
|
||||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
|
||||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
<EventDetailPanel
|
||||||
}`}
|
event={panelMode === 'view' ? selectedEvent : null}
|
||||||
>
|
isCreating={panelMode === 'create'}
|
||||||
<EventDetailPanel
|
createDefaults={createDefaults}
|
||||||
event={panelMode === 'view' ? selectedEvent : null}
|
onClose={handlePanelClose}
|
||||||
isCreating={panelMode === 'create'}
|
onSaved={handlePanelClose}
|
||||||
createDefaults={createDefaults}
|
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||||
onClose={handlePanelClose}
|
myPermission={selectedEventPermission}
|
||||||
onSaved={handlePanelClose}
|
isSharedEvent={selectedEventIsShared}
|
||||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
/>
|
||||||
myPermission={selectedEventPermission}
|
</div>
|
||||||
isSharedEvent={selectedEventIsShared}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile detail panel overlay */}
|
{/* Mobile detail panel overlay */}
|
||||||
{panelOpen && (
|
{panelOpen && !isDesktop && (
|
||||||
<div
|
<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}
|
onClick={handlePanelClose}
|
||||||
>
|
>
|
||||||
<div
|
<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() {
|
export function useAdminDashboard() {
|
||||||
return useQuery<AdminDashboardData>({
|
return useQuery<AdminDashboardData>({
|
||||||
queryKey: ['admin', 'dashboard'],
|
queryKey: ['admin', 'dashboard'],
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||||
|
|
||||||
export function useCalendars() {
|
interface UseCalendarsOptions {
|
||||||
|
pollingEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) {
|
||||||
const ownedQuery = useQuery({
|
const ownedQuery = useQuery({
|
||||||
queryKey: ['calendars'],
|
queryKey: ['calendars'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -18,7 +22,7 @@ export function useCalendars() {
|
|||||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: 5_000,
|
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||||
staleTime: 3_000,
|
staleTime: 3_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user