Clean up. Almost ready to deploy maybe. REally wanna rewrite but hey eventually we will.
This commit is contained in:
@ -33,7 +33,7 @@ const AvatarDropdown = () => {
|
||||
<BasedAvatar
|
||||
src={profile?.avatar_url}
|
||||
fullName={profile?.full_name}
|
||||
className='h-12 w-12 my-auto'
|
||||
className='lg:h-12 lg:w-12 my-auto'
|
||||
fallbackClassName='text-xl font-semibold'
|
||||
userIconSize={32}
|
||||
/>
|
||||
|
@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ThemeToggle, useTVMode } from '@/components/context';
|
||||
@ -9,45 +8,64 @@ import AvatarDropdown from './AvatarDropdown';
|
||||
const Header = () => {
|
||||
const { tvMode } = useTVMode();
|
||||
const { isAuthenticated } = useAuth();
|
||||
return tvMode ? (
|
||||
<div className='w-full py-2 pt-6 md:py-5'>
|
||||
<div className='absolute top-8 right-16'>
|
||||
<div className='flex flex-row my-auto items-center'>
|
||||
<ThemeToggle className='mr-4' />
|
||||
{isAuthenticated && <AvatarDropdown />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Controls component for both modes
|
||||
const Controls = () => (
|
||||
<div className='flex flex-row items-center'>
|
||||
<ThemeToggle className='mr-4' />
|
||||
{isAuthenticated && <AvatarDropdown />}
|
||||
</div>
|
||||
) : (
|
||||
<header className='w-full py-2 md:py-5'>
|
||||
<div className='absolute top-8 right-16'>
|
||||
<div className='flex flex-row my-auto items-center'>
|
||||
<ThemeToggle className='mr-4' />
|
||||
{isAuthenticated && <AvatarDropdown />}
|
||||
);
|
||||
|
||||
if (tvMode) {
|
||||
return (
|
||||
<div className='absolute top-10 right-37'>
|
||||
<Controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className='w-full mb-8'>
|
||||
<div className='container mx-auto px-4 md:px-6 lg:px-20'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Left spacer for perfect centering */}
|
||||
<div className='flex flex-1 justify-start'>
|
||||
<div className='sm:w-[120px] md:w-[160px]' />
|
||||
</div>
|
||||
|
||||
{/* Centered logo and title */}
|
||||
<div className='flex-shrink-0'>
|
||||
<Link
|
||||
href='/'
|
||||
scroll={false}
|
||||
className='flex flex-row items-center justify-center px-4'
|
||||
>
|
||||
<Image
|
||||
src='/favicon.png'
|
||||
alt='Tech Tracker Logo'
|
||||
width={100}
|
||||
height={100}
|
||||
className='max-w-[40px] md:max-w-[120px]'
|
||||
/>
|
||||
<h1 className='title-text text-sm md:text-4xl lg:text-8xl
|
||||
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
|
||||
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
||||
>
|
||||
Tech Tracker
|
||||
</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right-aligned controls */}
|
||||
<div className='flex-1 flex justify-end'>
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href='/'
|
||||
scroll={false}
|
||||
className='flex flex-row items-center text-center
|
||||
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
|
||||
>
|
||||
<Image
|
||||
src='/favicon.png'
|
||||
alt='Tech Tracker Logo'
|
||||
width={100}
|
||||
height={100}
|
||||
className='max-w-[40px] md:max-w-[120px]'
|
||||
/>
|
||||
<h1
|
||||
className='title-text text-sm md:text-4xl lg:text-8xl
|
||||
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
||||
>
|
||||
Tech Tracker
|
||||
</h1>
|
||||
</Link>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
68
src/components/status/ConnectionStatus.tsx
Normal file
68
src/components/status/ConnectionStatus.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui';
|
||||
import type { ConnectionStatus as ConnectionStatusType } from '@/lib/hooks';
|
||||
|
||||
type ConnectionStatusProps = {
|
||||
status: ConnectionStatusType;
|
||||
onReconnect?: () => void;
|
||||
showAsButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getConnectionIcon = (status: ConnectionStatusType) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <Wifi className='w-4 h-4 text-green-500' />;
|
||||
case 'connecting':
|
||||
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className='w-4 h-4 text-red-500' />;
|
||||
case 'updating':
|
||||
return <RefreshCw className='w-4 h-4 text-blue-500 animate-spin' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionText = (status: ConnectionStatusType) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
case 'updating':
|
||||
return 'Updating...';
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionStatus = ({
|
||||
status,
|
||||
onReconnect,
|
||||
showAsButton = false,
|
||||
className = '',
|
||||
}: ConnectionStatusProps) => {
|
||||
if (showAsButton && status === 'disconnected' && onReconnect) {
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReconnect}
|
||||
className={`flex items-center gap-2 cursor-pointer ${className}`}
|
||||
>
|
||||
{getConnectionIcon(status)}
|
||||
<span className='text-base'>{getConnectionText(status)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={`flex items-center gap-2 ${className}`}
|
||||
>
|
||||
{getConnectionIcon(status)}
|
||||
<span className='text-base'>{getConnectionText(status)}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
438
src/components/status/List.tsx
Executable file → Normal file
438
src/components/status/List.tsx
Executable file → Normal file
@ -1,35 +1,28 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import {
|
||||
getRecentUsersWithStatuses,
|
||||
updateStatuses,
|
||||
updateUserStatus,
|
||||
type UserWithStatus,
|
||||
} from '@/lib/hooks';
|
||||
import type { UserWithStatus } from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { toast } from 'sonner';
|
||||
import { HistoryDrawer } from '@/components/status';
|
||||
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Wifi, WifiOff, Calendar } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryErrorCodes } from '@/components/context';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import { RefreshCw, Clock, Calendar } from 'lucide-react';
|
||||
import { useStatusData, useSharedStatusSubscription } from '@/lib/hooks';
|
||||
import { formatTime, formatDate } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
|
||||
type ListProps = { initialStatuses: UserWithStatus[] };
|
||||
type ListProps = {
|
||||
initialStatuses: UserWithStatus[]
|
||||
};
|
||||
|
||||
export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
@ -38,216 +31,64 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
useState<Profile | null>(null);
|
||||
const [updateStatusMessage, setUpdateStatusMessage] = useState('');
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'disconnected' | 'updating'
|
||||
>('connecting');
|
||||
const [newStatuses, setNewStatuses] = useState<Set<UserWithStatus>>(new Set());
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const supabaseRef = useRef(createClient());
|
||||
|
||||
const {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
dataUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['users-with-statuses'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await getRecentUsersWithStatuses();
|
||||
if (!response.success) throw new Error(response.error);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(`Error fetching technicians: ${error as Error}`)
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // 30 Second interval as we should rely on subscription.
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
newStatuses,
|
||||
updateStatusMutation,
|
||||
} = useStatusData({
|
||||
initialData: initialStatuses,
|
||||
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const maxReconnectAttempts = 3;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimeout: NodeJS.Timeout;
|
||||
let isComponentMounted = true;
|
||||
let currentChannel: RealtimeChannel | null = null;
|
||||
|
||||
const setUpRealtimeConnection = () => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
if (currentChannel) {
|
||||
supabaseRef.current.removeChannel(currentChannel)
|
||||
.catch((error) => {
|
||||
setConnectionStatus('disconnected');
|
||||
console.error(`Error unsubscribing: ${error}`);
|
||||
})
|
||||
currentChannel = null;
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
const channel = supabaseRef.current
|
||||
.channel('status_updates')
|
||||
.on('broadcast', { event: 'status_updated' }, () => {
|
||||
refetch().catch((error) => {
|
||||
console.error(`Error refetching statuses: ${error as Error}`)
|
||||
});
|
||||
})
|
||||
.subscribe((status) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttempts = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR') {
|
||||
setConnectionStatus('disconnected');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CLOSED') {
|
||||
setConnectionStatus('disconnected')
|
||||
if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
const delay = 2000 * reconnectAttempts;
|
||||
console.log(
|
||||
`Reconnecting after close.
|
||||
${reconnectAttempts} attempts of
|
||||
${maxReconnectAttempts} in ${delay}ms
|
||||
`);
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) setUpRealtimeConnection();
|
||||
}, delay);
|
||||
} else {
|
||||
console.warn('Max reconnection attempts reached or component is not mounted.');
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
}
|
||||
});
|
||||
currentChannel = channel;
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
const initialTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) setUpRealtimeConnection();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
isComponentMounted = false;
|
||||
if (initialTimeout) clearTimeout(initialTimeout);
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
if (currentChannel) {
|
||||
supabaseRef.current.removeChannel(currentChannel)
|
||||
.catch((error) => {
|
||||
console.error(`Error unsubscribing: ${error as Error}`)
|
||||
});
|
||||
channelRef.current = null;
|
||||
}
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
}, [isAuthenticated, refetch]);
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
|
||||
mutationFn: async ({
|
||||
usersWithStatuses,
|
||||
status,
|
||||
}: {
|
||||
usersWithStatuses: UserWithStatus[];
|
||||
status: string;
|
||||
}) => {
|
||||
const previousConnectionStatus = connectionStatus;
|
||||
try {
|
||||
if (usersWithStatuses.length <= 0) {
|
||||
const result = await updateUserStatus(status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return result.data;
|
||||
} else {
|
||||
const result = await updateStatuses(usersWithStatuses, status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating statuses: ${error as Error}`);
|
||||
}
|
||||
},
|
||||
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
|
||||
|
||||
onMutate: async ({ usersWithStatuses, status }) => {
|
||||
// Optimistic update logic
|
||||
await queryClient.cancelQueries({ queryKey: ['users-with-statuses']} );
|
||||
const previousData = queryClient.getQueryData<UserWithStatus[]>([
|
||||
'users-with-statuses',
|
||||
]);
|
||||
if (previousData && usersWithStatuses.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const optimisticData = previousData.map((userStatus) => {
|
||||
if (usersWithStatuses.some((selected) => selected.user.id === userStatus.user.id))
|
||||
return { ...userStatus, status, created_at: now};
|
||||
return userStatus;
|
||||
});
|
||||
queryClient.setQueryData(['users-with-statuses'], optimisticData);
|
||||
|
||||
// Add animation to optimisticly updated statuses
|
||||
const updatedStatuses = usersWithStatuses;
|
||||
setNewStatuses((prev) => new Set([...prev, ...updatedStatuses]))
|
||||
setTimeout(() => {
|
||||
setNewStatuses((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updatedStatuses.forEach((updatedStatus) => updated.delete(updatedStatus));
|
||||
return updated;
|
||||
});
|
||||
}, 1000)
|
||||
}
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users-with-statuses']} )
|
||||
.catch((error) => console.error(`Error invalidating query: ${error}`));
|
||||
if (!data) return;
|
||||
data.forEach((statusUpdate) => {
|
||||
toast.success(`
|
||||
${statusUpdate.user.full_name}'s status updated to '${statusUpdate.status}'.
|
||||
`);
|
||||
});
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
},
|
||||
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previousData)
|
||||
queryClient.setQueryData(['users-with-statuses'], context.previousData);
|
||||
toast.error(`Error updating statuses: ${error}`);
|
||||
},
|
||||
// In your StatusList component
|
||||
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
|
||||
refetch().catch((error) => {
|
||||
console.error('Error refetching statuses:', error);
|
||||
});
|
||||
});
|
||||
|
||||
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
|
||||
//enabled: isAuthenticated,
|
||||
//onStatusUpdate: () => {
|
||||
//refetch().catch((error) => {
|
||||
//console.error('Error refetching statuses:', error);
|
||||
//});
|
||||
//},
|
||||
//});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
setUpdateStatusMessage('You must be signed in to update technican statuses!')
|
||||
return;
|
||||
} else if (!statusInput.trim()) {
|
||||
setUpdateStatusMessage('Your status must be in between 3 & 80 characters long!');
|
||||
setUpdateStatusMessage(
|
||||
'Error: You must be signed in to update technician statuses!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusInput.length < 3 || statusInput.length > 80) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: Your status must be between 3 & 80 characters long!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
setUpdateStatusMessage('');
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (user: UserWithStatus) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.user.id === user.user.id)
|
||||
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
|
||||
: [...prev, user],
|
||||
: [...prev, user]
|
||||
);
|
||||
};
|
||||
|
||||
@ -263,52 +104,10 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
useEffect(() => {
|
||||
setSelectAll(
|
||||
selectedUsers.length === usersWithStatuses.length &&
|
||||
usersWithStatuses.length > 0,
|
||||
usersWithStatuses.length > 0
|
||||
);
|
||||
}, [selectedUsers.length, usersWithStatuses.length]);
|
||||
|
||||
const getConnectionIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return <Wifi className='w-4 h-4 text-green-500' />;
|
||||
case 'connecting':
|
||||
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className='w-4 h-4 text-red-500' />;
|
||||
case 'updating':
|
||||
return <RefreshCw className='w-4 h-4 text-blue-500 animate-spin' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionText = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
case 'updating':
|
||||
return 'Updating...';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const time = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
return `${time}`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
return `${month} ${day}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-[400px]'>
|
||||
@ -360,38 +159,56 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
|
||||
<div className={headerClassName}>
|
||||
<div className='flex items-center gap-10'>
|
||||
<div className='flex gap-2'>
|
||||
<Checkbox
|
||||
id='select-all'
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAllChange}
|
||||
className='size-6'
|
||||
/>
|
||||
<label htmlFor='select-all' className='font-medium'>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Miss the old table?</p>
|
||||
<Link
|
||||
href='/status/table'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
>
|
||||
Find it here!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='select-all'
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAllChange}
|
||||
<ConnectionStatus
|
||||
status={connectionStatus}
|
||||
onReconnect={reconnect}
|
||||
showAsButton={connectionStatus === 'disconnected'}
|
||||
/>
|
||||
<label htmlFor='select-all' className='text-sm font-medium'>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className='flex items-center gap-2'>
|
||||
{getConnectionIcon()}
|
||||
<span className='text-sm'>{getConnectionText()}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardContainerClassName}>
|
||||
{usersWithStatuses.map((userWithStatus) => {
|
||||
const isSelected = selectedUsers.some((u) => u.user.id === userWithStatus.user.id);
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id
|
||||
);
|
||||
const isNewStatus = newStatuses.has(userWithStatus);
|
||||
const isUpdatedByOther = userWithStatus.updated_by &&
|
||||
const isUpdatedByOther =
|
||||
userWithStatus.updated_by &&
|
||||
userWithStatus.updated_by.id !== userWithStatus.user.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${cardClassName}
|
||||
${isSelected ? 'ring2 ring-primary' : ''}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
`}
|
||||
>
|
||||
@ -401,7 +218,9 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
{!tvMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleCheckboxChange(userWithStatus)}
|
||||
onCheckedChange={() =>
|
||||
handleCheckboxChange(userWithStatus)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
@ -411,7 +230,9 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
className={tvMode ? 'w-24 h-24' : 'w-16 h-16'}
|
||||
/>
|
||||
<div className='my-auto'>
|
||||
<h3 className={`font-semibold ${tvMode ? 'text-5xl' : 'text-2xl'}`}>
|
||||
<h3
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-2xl'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name}
|
||||
</h3>
|
||||
{isUpdatedByOther && (
|
||||
@ -421,7 +242,9 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
<span className={`${tvMode ? 'text-3xl' : 'text-sm'}`}>
|
||||
<span
|
||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by?.full_name}
|
||||
</span>
|
||||
</div>
|
||||
@ -436,7 +259,9 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Calendar className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`} />
|
||||
<Calendar
|
||||
className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`}
|
||||
/>
|
||||
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</span>
|
||||
@ -444,7 +269,6 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='pt-0'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
@ -454,14 +278,16 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
transition-colors cursor-pointer text-left
|
||||
${tvMode ? 'text-4xl' : 'text-xl'}
|
||||
`}
|
||||
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
<p className='font-medium'>{userWithStatus.status}</p>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -471,13 +297,77 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<Card className='p-8 text-center'>
|
||||
<p className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates have been made in the past day.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!tvMode && (
|
||||
<Card className='p-6 mt-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='Enter status'
|
||||
className='flex-1 text-base'
|
||||
value={statusInput}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!updateStatusMutation.isPending
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className='px-6'
|
||||
>
|
||||
{selectedUsers.length > 0
|
||||
? `Update status for ${selectedUsers.length}
|
||||
${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'
|
||||
}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{updateStatusMessage &&
|
||||
(updateStatusMessage.includes('Error') ||
|
||||
updateStatusMessage.includes('error') ||
|
||||
updateStatusMessage.includes('failed') ||
|
||||
updateStatusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: updateStatusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: updateStatusMessage }} />
|
||||
))}
|
||||
</div>
|
||||
<div className='flex justify-center mt-2'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={tvMode ? 'text-3xl p-6' : ''}
|
||||
>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
@ -1,564 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import {
|
||||
getRecentUsersWithStatuses,
|
||||
updateStatuses,
|
||||
updateUserStatus,
|
||||
type UserWithStatus,
|
||||
} from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { toast } from 'sonner';
|
||||
import { HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryErrorCodes } from '@/components/context';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
type StatusListProps = {
|
||||
initialStatuses: UserWithStatus[];
|
||||
};
|
||||
|
||||
export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
|
||||
// Fixed props destructuring
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [selectedHistoryUser, setSelectedHistoryUser] =
|
||||
useState<Profile | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'disconnected'
|
||||
>('connecting');
|
||||
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const supabaseRef = useRef(createClient());
|
||||
|
||||
// Keep all your existing React Query code exactly as is
|
||||
const {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
dataUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['users-with-statuses'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await getRecentUsersWithStatuses();
|
||||
if (!response.success) throw new Error(response.error);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Changed to 30 seconds as backup
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
initialData: initialStatuses,
|
||||
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
|
||||
});
|
||||
|
||||
// Add this new useEffect for realtime enhancement
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 3;
|
||||
let reconnectTimeout: NodeJS.Timeout;
|
||||
let isComponentMounted = true;
|
||||
let currentChannel: RealtimeChannel | null = null;
|
||||
|
||||
const setUpRealtimeConnection = () => {
|
||||
if (!isComponentMounted) return;
|
||||
if (currentChannel) {
|
||||
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||
console.error(`Error unsubscribing: ${error}`);
|
||||
});
|
||||
currentChannel = null;
|
||||
}
|
||||
setConnectionStatus('connecting');
|
||||
const channel = supabaseRef.current
|
||||
.channel('status_updates')
|
||||
.on('broadcast', { event: 'status_updated' }, (payload) => {
|
||||
console.log('Realtime update received, triggering refetch...');
|
||||
refetch().catch((error) => {
|
||||
console.error(`Error refetching: ${error}`);
|
||||
});
|
||||
})
|
||||
.subscribe((status) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
console.log('Realtime connection established');
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttempts = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR') {
|
||||
console.log('Realtime connection failed, relying on polling');
|
||||
setConnectionStatus('disconnected');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CLOSED') {
|
||||
console.log('Realtime connection closed');
|
||||
setConnectionStatus('disconnected');
|
||||
if (
|
||||
isComponentMounted &&
|
||||
reconnectAttempts < maxReconnectAttempts
|
||||
) {
|
||||
reconnectAttempts++;
|
||||
const delay = 2000 * reconnectAttempts;
|
||||
|
||||
console.log(
|
||||
`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`,
|
||||
);
|
||||
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
}
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
setUpRealtimeConnection();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
console.log(
|
||||
'Max reconnection attempts reached or component unmounted',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
currentChannel = channel;
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
const initialTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
setUpRealtimeConnection();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
isComponentMounted = false;
|
||||
if (initialTimeout) clearTimeout(initialTimeout);
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
|
||||
if (currentChannel) {
|
||||
console.log('Cleaning up realtime connection...');
|
||||
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||
console.error(`Error unsubscribing: ${error}`);
|
||||
});
|
||||
channelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, refetch]);
|
||||
|
||||
// Updated mutation
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
usersWithStatuses,
|
||||
status,
|
||||
}: {
|
||||
usersWithStatuses: UserWithStatus[];
|
||||
status: string;
|
||||
}) => {
|
||||
if (usersWithStatuses.length === 0) {
|
||||
const result = await updateUserStatus(status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { type: 'single', result };
|
||||
} else {
|
||||
const result = await updateStatuses(usersWithStatuses, status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { type: 'multiple', result, count: usersWithStatuses.length };
|
||||
}
|
||||
},
|
||||
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
|
||||
onMutate: async ({ usersWithStatuses, status }) => {
|
||||
// Optimistic update logic
|
||||
await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] });
|
||||
const previousData = queryClient.getQueryData<UserWithStatus[]>([
|
||||
'users-with-statuses',
|
||||
]);
|
||||
|
||||
if (previousData && usersWithStatuses.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const optimisticData = previousData.map((userStatus) => {
|
||||
if (
|
||||
usersWithStatuses.some(
|
||||
(selected) => selected.user.id === userStatus.user.id,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...userStatus,
|
||||
status,
|
||||
created_at: now,
|
||||
};
|
||||
}
|
||||
return userStatus;
|
||||
});
|
||||
queryClient.setQueryData(['users-with-statuses'], optimisticData);
|
||||
|
||||
// Add animation for optimistic updates
|
||||
const updatedIds = usersWithStatuses.map((u) => u.user.id);
|
||||
setNewStatusIds((prev) => new Set([...prev, ...updatedIds]));
|
||||
|
||||
// Remove animation after 1 second
|
||||
setTimeout(() => {
|
||||
setNewStatusIds((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updatedIds.forEach((id) => updated.delete(id));
|
||||
return updated;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Handle success in the mutation function
|
||||
void queryClient.invalidateQueries({ queryKey: ['users-with-statuses'] }); // Fixed floating promise
|
||||
|
||||
if (data.type === 'single') {
|
||||
toast.success('Status updated for signed in user.');
|
||||
} else {
|
||||
toast.success(`Status updated for ${data.count} selected users.`);
|
||||
}
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
// Fixed unused variables
|
||||
// Rollback optimistic update
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(['users-with-statuses'], context.previousData);
|
||||
}
|
||||
// Error handling is done in the global mutation cache
|
||||
console.error('Status update failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error('You must be signed in to update statuses.');
|
||||
return;
|
||||
}
|
||||
if (!statusInput.trim()) {
|
||||
toast.error('Please enter a valid status.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (user: UserWithStatus) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.user.id === user.user.id)
|
||||
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
|
||||
: [...prev, user],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllChange = () => {
|
||||
if (selectAll) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(usersWithStatuses);
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectAll(
|
||||
selectedUsers.length === usersWithStatuses.length &&
|
||||
usersWithStatuses.length > 0,
|
||||
);
|
||||
}, [selectedUsers.length, usersWithStatuses.length]);
|
||||
|
||||
const getConnectionIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return <Wifi className='w-4 h-4 text-green-500' />;
|
||||
case 'connecting':
|
||||
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className='w-4 h-4 text-red-500' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionText = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const time = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
return `${time} - ${month} ${day}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-[400px]'>
|
||||
<Loading className='w-full' alpha={0.5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
|
||||
<p className='text-red-500'>Error loading status updates</p>
|
||||
<Button onClick={() => refetch()} variant='outline'>
|
||||
<RefreshCw className='w-4 h-4 mr-2' />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'mx-auto space-y-4',
|
||||
on: 'lg:w-11/12 w-full mt-15',
|
||||
off: 'w-5/6',
|
||||
});
|
||||
|
||||
const cardClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'transition-all duration-300 hover:shadow-md',
|
||||
on: 'lg:text-4xl',
|
||||
off: 'lg:text-base',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{/* Status Header */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className={`font-bold ${tvMode ? 'text-6xl' : 'text-2xl'}`}>
|
||||
Tech Status
|
||||
</h2>
|
||||
{isFetching ? (
|
||||
<Badge variant='outline' className='flex items-center gap-2'>
|
||||
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
|
||||
<span className='text-xs'>Updating...</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant='outline' className='flex items-center gap-2'>
|
||||
{getConnectionIcon()}
|
||||
<span className='text-xs'>{getConnectionText()}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!tvMode && usersWithStatuses.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='select-all'
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAllChange}
|
||||
/>
|
||||
<label htmlFor='select-all' className='text-sm font-medium'>
|
||||
Select All ({selectedUsers.length} selected)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className='space-y-3'>
|
||||
{usersWithStatuses.map((userWithStatus) => {
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id,
|
||||
);
|
||||
const isNewStatus = newStatusIds.has(userWithStatus.user.id);
|
||||
const isUpdatedByOther =
|
||||
userWithStatus.updated_by &&
|
||||
userWithStatus.updated_by.id !== userWithStatus.user.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${cardClassName}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
hover:bg-muted/50 cursor-pointer
|
||||
`}
|
||||
>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{!tvMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
handleCheckboxChange(userWithStatus)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<h3
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
||||
</h3>
|
||||
{isUpdatedByOther && (
|
||||
<div className='flex items-center gap-1 text-muted-foreground'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.updated_by?.avatar_url}
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className='w-3 h-3'
|
||||
/>
|
||||
{userWithStatus.updated_by && (
|
||||
<span
|
||||
className={`text-xs ${tvMode ? 'text-3xl' : ''}`}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by.full_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Clock className={`${tvMode ? 'w-8 h-8' : 'w-4 h-4'}`} />
|
||||
<span className={`text-sm ${tvMode ? 'text-3xl' : ''}`}>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
className={`
|
||||
p-4 rounded-lg bg-muted/30 hover:bg-muted/50
|
||||
transition-colors cursor-pointer text-left
|
||||
${tvMode ? 'text-4xl' : 'text-base'}
|
||||
`}
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
<p className='font-medium'>{userWithStatus.status}</p>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<Card className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates yet
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status Update Input */}
|
||||
{!tvMode && (
|
||||
<Card className='p-6 mt-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||
<div className='flex gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder="What's your status?"
|
||||
className='flex-1 text-base'
|
||||
value={statusInput}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!updateStatusMutation.isPending
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
/>
|
||||
<SubmitButton
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className='px-6'
|
||||
>
|
||||
{updateStatusMutation.isPending
|
||||
? 'Updating...'
|
||||
: selectedUsers.length > 0
|
||||
? `Update ${selectedUsers.length} Users`
|
||||
: 'Update Status'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{selectedUsers.length > 0 && (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Updating status for {selectedUsers.length} selected users
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global Status History Drawer */}
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
377
src/components/status/Table.tsx
Normal file
377
src/components/status/Table.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import type { UserWithStatus } from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Calendar } from 'lucide-react';
|
||||
import { useSharedStatusSubscription, useStatusData } from '@/lib/hooks';
|
||||
import { formatTime, formatDate } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
type TableProps = {
|
||||
initialStatuses: UserWithStatus[];
|
||||
};
|
||||
|
||||
export const TechTable = ({ initialStatuses = [] }: TableProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [selectedHistoryUser, setSelectedHistoryUser] =
|
||||
useState<Profile | null>(null);
|
||||
const [updateStatusMessage, setUpdateStatusMessage] = useState('');
|
||||
|
||||
const {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
newStatuses,
|
||||
updateStatusMutation,
|
||||
} = useStatusData({
|
||||
initialData: initialStatuses,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
// In your StatusList component
|
||||
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
|
||||
refetch().catch((error) => {
|
||||
console.error('Error refetching statuses:', error);
|
||||
});
|
||||
});
|
||||
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
|
||||
//enabled: isAuthenticated,
|
||||
//onStatusUpdate: () => {
|
||||
//refetch().catch((error) => {
|
||||
//console.error('Error refetching statuses:', error);
|
||||
//});
|
||||
//},
|
||||
//});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: You must be signed in to update technician statuses!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusInput.length < 3 || statusInput.length > 80) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: Your status must be between 3 & 80 characters long!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
setUpdateStatusMessage('');
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (user: UserWithStatus) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.user.id === user.user.id)
|
||||
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
|
||||
: [...prev, user]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllChange = () => {
|
||||
if (selectAll) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(usersWithStatuses);
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectAll(
|
||||
selectedUsers.length === usersWithStatuses.length &&
|
||||
usersWithStatuses.length > 0
|
||||
);
|
||||
}, [selectedUsers.length, usersWithStatuses.length]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-[400px]'>
|
||||
<Loading className='w-full' alpha={0.5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
|
||||
<p className='text-red-500'>Error loading status updates</p>
|
||||
<Button onClick={() => refetch()} variant='outline'>
|
||||
<RefreshCw className='w-4 h-4 mr-2' />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'mx-auto',
|
||||
on: 'lg:w-11/12 w-full',
|
||||
off: 'w-5/6',
|
||||
});
|
||||
const headerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full mb-2 flex justify-between',
|
||||
on: 'mt-25',
|
||||
off: 'mb-2',
|
||||
});
|
||||
const thClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-4 px-4 border font-semibold xl:min-w-[420px]',
|
||||
on: 'lg:text-6xl',
|
||||
off: 'lg:text-5xl',
|
||||
});
|
||||
const tdClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-2 px-2 border',
|
||||
on: 'lg:text-5xl',
|
||||
off: 'lg:text-4xl',
|
||||
});
|
||||
const tCheckboxClassName = `py-3 px-4 border`;
|
||||
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConnectionStatus
|
||||
status={connectionStatus}
|
||||
onReconnect={reconnect}
|
||||
showAsButton={connectionStatus === 'disconnected'}
|
||||
/>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Tired of the old table? {' '}</p>
|
||||
<Link
|
||||
href='/status/list'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
>
|
||||
Try out the new status list!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAllChange}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className={thClassName}>Technician</th>
|
||||
<th className={thClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
|
||||
Status
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</th>
|
||||
<th className={thClassName}>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersWithStatuses.map((userWithStatus, index) => {
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id,
|
||||
);
|
||||
const isNewStatus = newStatuses.has(userWithStatus);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{!tvMode && (
|
||||
<td className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={isSelected}
|
||||
onChange={() => handleCheckboxChange(userWithStatus)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdClassName}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-4xl'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
||||
</p>
|
||||
{userWithStatus.updated_by &&
|
||||
userWithStatus.updated_by.id !==
|
||||
userWithStatus.user.id && (
|
||||
<div className='flex items-center gap-1 text-muted-foreground'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.updated_by?.avatar_url}
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
<span
|
||||
className={tvMode ? 'text-lg' : 'text-base'}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger
|
||||
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
{userWithStatus.status}
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
<div className='flex w-full'>
|
||||
<div className='flex items-start xl:w-1/6'></div>
|
||||
<div className='flex flex-col my-auto items-start'>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Clock className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`} />
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</div>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
|
||||
/>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<div className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatusMessage &&
|
||||
(updateStatusMessage.includes('Error') ||
|
||||
updateStatusMessage.includes('error') ||
|
||||
updateStatusMessage.includes('failed') ||
|
||||
updateStatusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: updateStatusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: updateStatusMessage }} />
|
||||
))}
|
||||
|
||||
{!tvMode && (
|
||||
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='New Status'
|
||||
className={
|
||||
'min-w-[120px] lg:max-w-[400px] py-6 px-3 rounded-xl \
|
||||
border bg-background lg:text-2xl focus:outline-none \
|
||||
focus:ring-2 focus:ring-primary'
|
||||
}
|
||||
value={statusInput}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !updateStatusMutation.isPending) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
/>
|
||||
<SubmitButton
|
||||
size='xl'
|
||||
className={
|
||||
'px-8 rounded-xl font-semibold lg:text-2xl \
|
||||
disabled:opacity-50 disabled:cursor-not-allowed \
|
||||
cursor-pointer'
|
||||
}
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
pendingText='Updating...'
|
||||
>
|
||||
{selectedUsers.length > 0
|
||||
? `Update status for ${selectedUsers.length}
|
||||
${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'
|
||||
}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Status History Drawer */}
|
||||
{!tvMode && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,551 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import {
|
||||
getRecentUsersWithStatuses,
|
||||
updateStatuses,
|
||||
updateUserStatus,
|
||||
type UserWithStatus,
|
||||
} from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { toast } from 'sonner';
|
||||
import { HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryErrorCodes } from '@/components/context';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
type TechTableProps = {
|
||||
initialStatuses: UserWithStatus[];
|
||||
};
|
||||
|
||||
export const TechTable = ({ initialStatuses = [] }: TechTableProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [selectedHistoryUser, setSelectedHistoryUser] =
|
||||
useState<Profile | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'disconnected'
|
||||
>('connecting');
|
||||
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const supabaseRef = useRef(createClient());
|
||||
|
||||
// Keep all your existing React Query code exactly as is
|
||||
const {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
dataUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['users-with-statuses'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await getRecentUsersWithStatuses();
|
||||
if (!response.success) throw new Error(response.error);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Changed to 30 seconds as backup
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
initialData: initialStatuses,
|
||||
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
|
||||
});
|
||||
|
||||
// Add this new useEffect for realtime enhancement
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 3;
|
||||
let reconnectTimeout: NodeJS.Timeout;
|
||||
let isComponentMounted = true;
|
||||
let currentChannel: RealtimeChannel | null = null;
|
||||
|
||||
const setUpRealtimeConnection = () => {
|
||||
if (!isComponentMounted) return;
|
||||
if (currentChannel) {
|
||||
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||
console.error(`Error unsubscribing: ${error}`);
|
||||
});
|
||||
currentChannel = null;
|
||||
}
|
||||
setConnectionStatus('connecting');
|
||||
const channel = supabaseRef.current
|
||||
.channel('status_updates')
|
||||
.on('broadcast', { event: 'status_updated' }, (payload) => {
|
||||
console.log('Realtime update received, triggering refetch...');
|
||||
refetch().catch((error) => {
|
||||
console.error(`Error refetching: ${error}`);
|
||||
});
|
||||
})
|
||||
.subscribe((status) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
console.log('Realtime connection established');
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttempts = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR') {
|
||||
console.log('Realtime connection failed, relying on polling');
|
||||
setConnectionStatus('disconnected');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CLOSED') {
|
||||
console.log('Realtime connection closed');
|
||||
setConnectionStatus('disconnected');
|
||||
if (
|
||||
isComponentMounted &&
|
||||
reconnectAttempts < maxReconnectAttempts
|
||||
) {
|
||||
reconnectAttempts++;
|
||||
const delay = 2000 * reconnectAttempts;
|
||||
console.log(
|
||||
`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`,
|
||||
);
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
}
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
setUpRealtimeConnection();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
console.log(
|
||||
'Max reconnection attempts reached or component unmounted',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
currentChannel = channel;
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
const initialTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
setUpRealtimeConnection();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
isComponentMounted = false;
|
||||
if (initialTimeout) clearTimeout(initialTimeout);
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
if (currentChannel) {
|
||||
console.log('Cleaning up realtime connection...');
|
||||
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||
console.error(`Error unsubscribing: ${error}`);
|
||||
});
|
||||
channelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, refetch]);
|
||||
|
||||
// Updated mutation
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
usersWithStatuses,
|
||||
status,
|
||||
}: {
|
||||
usersWithStatuses: UserWithStatus[];
|
||||
status: string;
|
||||
}) => {
|
||||
if (usersWithStatuses.length === 0) {
|
||||
const result = await updateUserStatus(status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { type: 'single', result };
|
||||
} else {
|
||||
const result = await updateStatuses(usersWithStatuses, status);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { type: 'multiple', result, count: usersWithStatuses.length };
|
||||
}
|
||||
},
|
||||
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
|
||||
onMutate: async ({ usersWithStatuses, status }) => {
|
||||
// Optimistic update logic
|
||||
await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] });
|
||||
const previousData = queryClient.getQueryData<UserWithStatus[]>([
|
||||
'users-with-statuses',
|
||||
]);
|
||||
if (previousData && usersWithStatuses.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const optimisticData = previousData.map((userStatus) => {
|
||||
if (
|
||||
usersWithStatuses.some(
|
||||
(selected) => selected.user.id === userStatus.user.id,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...userStatus,
|
||||
status,
|
||||
created_at: now,
|
||||
};
|
||||
}
|
||||
return userStatus;
|
||||
});
|
||||
queryClient.setQueryData(['users-with-statuses'], optimisticData);
|
||||
// Add animation for optimistic updates
|
||||
const updatedIds = usersWithStatuses.map((u) => u.user.id);
|
||||
setNewStatusIds((prev) => new Set([...prev, ...updatedIds]));
|
||||
// Remove animation after 1 second
|
||||
setTimeout(() => {
|
||||
setNewStatusIds((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updatedIds.forEach((id) => updated.delete(id));
|
||||
return updated;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return { previousData };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Handle success in the mutation function
|
||||
void queryClient.invalidateQueries({ queryKey: ['users-with-statuses'] }); // Fixed floating promise
|
||||
if (data.type === 'single') {
|
||||
toast.success('Status updated for signed in user.');
|
||||
} else {
|
||||
toast.success(`Status updated for ${data.count} selected users.`);
|
||||
}
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
// Fixed unused variables
|
||||
// Rollback optimistic update
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(['users-with-statuses'], context.previousData);
|
||||
}
|
||||
// Error handling is done in the global mutation cache
|
||||
console.error('Status update failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error('You must be signed in to update statuses.');
|
||||
return;
|
||||
}
|
||||
if (!statusInput.trim()) {
|
||||
toast.error('Please enter a valid status.');
|
||||
return;
|
||||
}
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (user: UserWithStatus) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.user.id === user.user.id)
|
||||
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
|
||||
: [...prev, user],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllChange = () => {
|
||||
if (selectAll) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(usersWithStatuses);
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectAll(
|
||||
selectedUsers.length === usersWithStatuses.length &&
|
||||
usersWithStatuses.length > 0,
|
||||
);
|
||||
}, [selectedUsers.length, usersWithStatuses.length]);
|
||||
|
||||
const getConnectionIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return <Wifi className='w-4 h-4 text-green-500' />;
|
||||
case 'connecting':
|
||||
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className='w-4 h-4 text-red-500' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionText = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const time = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
return `${time} - ${month} ${day}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-[400px]'>
|
||||
<Loading className='w-full' alpha={0.5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
|
||||
<p className='text-red-500'>Error loading status updates</p>
|
||||
<Button onClick={() => refetch()} variant='outline'>
|
||||
<RefreshCw className='w-4 h-4 mr-2' />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'mx-auto',
|
||||
on: 'lg:w-11/12 w-full mt-15',
|
||||
off: 'w-5/6',
|
||||
});
|
||||
const thClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-3 px-4 border font-semibold',
|
||||
on: 'lg:text-6xl',
|
||||
off: 'lg:text-5xl',
|
||||
});
|
||||
const tdClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-3 px-4 border',
|
||||
on: 'lg:text-5xl',
|
||||
off: 'lg:text-4xl',
|
||||
});
|
||||
const tCheckboxClassName = `py-3 px-4 border`;
|
||||
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{/* Status Header */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
{isFetching ? (
|
||||
<Badge variant='outline' className='flex items-center gap-2'>
|
||||
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
|
||||
<span className='text-xs'>Updating...</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant='outline' className='flex items-center gap-2'>
|
||||
{getConnectionIcon()}
|
||||
<span className='text-xs'>{getConnectionText()}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAllChange}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className={thClassName}>Technician</th>
|
||||
<th className={thClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
|
||||
Status
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</th>
|
||||
<th className={thClassName}>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersWithStatuses.map((userWithStatus, index) => {
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id,
|
||||
);
|
||||
const isNewStatus = newStatusIds.has(userWithStatus.user.id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{!tvMode && (
|
||||
<td className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={isSelected}
|
||||
onChange={() => handleCheckboxChange(userWithStatus)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdClassName}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
||||
</p>
|
||||
{userWithStatus.updated_by &&
|
||||
userWithStatus.updated_by.id !==
|
||||
userWithStatus.user.id && (
|
||||
<div className='flex items-center gap-1 text-muted-foreground'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.updated_by?.avatar_url}
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className='w-3 h-3'
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${tvMode ? 'text-3xl' : ''}`}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger
|
||||
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
{userWithStatus.status}
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<div className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tvMode && (
|
||||
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
|
||||
<input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='New Status'
|
||||
className={
|
||||
'min-w-[120px] lg:min-w-[400px] py-2 px-3 rounded-xl \
|
||||
border bg-background lg:text-2xl focus:outline-none \
|
||||
focus:ring-2 focus:ring-primary'
|
||||
}
|
||||
value={statusInput}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !updateStatusMutation.isPending) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
/>
|
||||
<SubmitButton
|
||||
size='xl'
|
||||
className={
|
||||
'px-8 rounded-xl font-semibold lg:text-2xl \
|
||||
disabled:opacity-50 disabled:cursor-not-allowed \
|
||||
cursor-pointer'
|
||||
}
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={!statusInput.trim() || updateStatusMutation.isPending}
|
||||
disabledNotLoading={true}
|
||||
>
|
||||
{updateStatusMutation.isPending
|
||||
? 'Updating...'
|
||||
: selectedUsers.length > 0
|
||||
? `Update ${selectedUsers.length} Users`
|
||||
: 'Update Status'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUsers.length > 0 && !tvMode && (
|
||||
<div className='text-center mt-4'>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Updating status for {selectedUsers.length} selected users
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Status History Drawer */}
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export * from './ConnectionStatus';
|
||||
export * from './HistoryDrawer';
|
||||
export * from './StatusList';
|
||||
export * from './TechTable';
|
||||
export * from './List';
|
||||
export * from './Table';
|
||||
|
Reference in New Issue
Block a user