Files
tech-tracker-next/src/components/status/TechTable.tsx
2025-06-13 13:36:13 -05:00

306 lines
9.9 KiB
TypeScript
Executable File

'use client';
import { createClient } from '@/utils/supabase';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuth, useTVMode } from '@/components/context';
import {
getRecentUsersWithStatuses,
updateStatuses,
updateUserStatus,
type UserWithStatus,
} from '@/lib/hooks/status';
import { Drawer, DrawerTrigger, Loading } from '@/components/ui';
import { toast } from 'sonner';
import { HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
type TechTableProps = {
initialStatuses: UserWithStatus[];
className?: string;
};
export const TechTable = ({
initialStatuses = [],
className = 'w-full max-w-7xl mx-auto px-4',
}: TechTableProps) => {
const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [usersWithStatuses, setUsersWithStatuses] =
useState<UserWithStatus[]>(initialStatuses);
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null);
const fetchRecentUsersWithStatuses = useCallback(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}`);
return [];
}
}, []);
// Initial load
useEffect(() => {
const loadData = async () => {
const data = await fetchRecentUsersWithStatuses();
setUsersWithStatuses(data);
setLoading(false);
};
loadData().catch((error) => {
console.error('Error loading data:', error);
});
}, [fetchRecentUsersWithStatuses, isAuthenticated]);
const updateStatus = useCallback(async () => {
if (!isAuthenticated) {
toast.error('You must be signed in to update statuses.');
return;
}
if (!statusInput.trim()) {
toast.error('Please enter a valid status.');
return;
}
try {
if (selectedIds.length === 0) {
const result = await updateUserStatus(statusInput);
if (!result.success) throw new Error(result.error);
toast.success(`Status updated for signed in user.`);
} else {
const result = await updateStatuses(selectedIds, statusInput);
if (!result.success) throw new Error(result.error);
toast.success(
`Status updated for ${selectedIds.length} selected users.`,
);
}
setSelectedIds([]);
setStatusInput('');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to update status: ${errorMessage}`);
}
}, [isAuthenticated, statusInput, selectedIds]);
const handleCheckboxChange = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const handleSelectAllChange = () => {
if (selectAll) {
setSelectedIds([]);
} else {
setSelectedIds(usersWithStatuses.map((tech) => tech.user.id));
}
setSelectAll(!selectAll);
};
useEffect(() => {
setSelectAll(
selectedIds.length === usersWithStatuses.length &&
usersWithStatuses.length > 0,
);
}, [selectedIds.length, usersWithStatuses.length]);
useEffect(() => {
if (!isAuthenticated) return;
const supabase = createClient();
const channel = supabase
.channel('status_updates', {
config: { broadcast: { self: true }}
})
.on('broadcast', { event: 'status_updated' }, (payload) => {
const { user_status } = payload.payload as {
user_status: UserWithStatus;
timestamp: string;
};
console.log('Received status update:', user_status);
setUsersWithStatuses((prevUsers) => {
const existingUserIndex = prevUsers.findIndex((u) =>
u.user.id === user_status.user.id,
);
if (existingUserIndex !== -1) {
const updatedUsers = [...prevUsers];
updatedUsers[existingUserIndex] = {
user: user_status.user, // Use the user from the broadcast
status: user_status.status,
created_at: user_status.created_at,
updated_by: user_status.updated_by,
};
return updatedUsers;
} else {
// Add new user to list!
return [user_status, ...prevUsers];
}
});
})
.subscribe((status) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
console.log('Successfully subscribed to status updates!');
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR') {
console.error('Error subscribing to status updates.')
}
});
channelRef.current = channel;
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current).catch((error) => {
console.error(`Error unsubscribing from status updates: ${error}`);
});
channelRef.current = null;
}
};
}, [isAuthenticated]);
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>
);
}
return (
<div className={className}>
<table
className={`w-full text-center border-collapse \
${tvMode ? 'text-4xl lg:text-5xl' : 'text-base lg:text-lg'}`}
>
<thead>
<tr className='bg-muted'>
{!tvMode && (
<th className='py-3 px-3 border'>
<input
type='checkbox'
className='scale-125 cursor-pointer'
checked={selectAll}
onChange={handleSelectAllChange}
/>
</th>
)}
<th className='py-3 px-4 border font-semibold'>Name</th>
<th className='py-3 px-4 border font-semibold'>
<Drawer>
<DrawerTrigger className='hover:underline'>
Status
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</th>
<th className='py-3 px-4 border font-semibold'>Updated At</th>
</tr>
</thead>
<tbody>
{usersWithStatuses.map((userWithStatus, index) => (
<tr
key={userWithStatus.user.id}
className={`
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
hover:bg-muted/75 transition-all duration-300
`}
>
{!tvMode && (
<td className='py-2 px-3 border'>
<input
type='checkbox'
className='scale-125 cursor-pointer'
checked={selectedIds.includes(userWithStatus.user.id)}
onChange={() =>
handleCheckboxChange(userWithStatus.user.id)
}
/>
</td>
)}
<td className='py-3 px-4 border font-medium'>
{userWithStatus.user.full_name ?? 'Unknown User'}
</td>
<td className='py-3 px-4 border'>
<Drawer>
<DrawerTrigger
className='text-left w-full p-2 rounded hover:bg-muted transition-colors'
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
>
{userWithStatus.status}
</DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && (
<HistoryDrawer user={selectedHistoryUser} />
)}
</Drawer>
</td>
<td className='py-3 px-4 border text-muted-foreground'>
{formatTime(userWithStatus.created_at)}
</td>
</tr>
))}
</tbody>
</table>
{!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') {
updateStatus().catch((error) => {
toast.error(`Failed to update status: ${error as Error}`);
});
}
}}
/>
<button
type='submit'
className={
'min-w-[100px] lg:min-w-[160px] py-2 px-4 rounded-xl \
text-center font-semibold lg:text-2xl bg-primary \
text-primary-foreground hover:bg-primary/90 \
transition-colors disabled:opacity-50 \
disabled:cursor-not-allowed'
}
onClick={() => void updateStatus()}
disabled={!statusInput.trim()}
>
Update
</button>
</div>
)}
</div>
);
};