297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
'use client';
|
|
import Link from 'next/link';
|
|
import { useState } from 'react';
|
|
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
|
import { api } from '~/convex/_generated/api';
|
|
import { type Id } from '~/convex/_generated/dataModel';
|
|
import { useTVMode } from '@/components/providers';
|
|
import {
|
|
BasedAvatar,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Drawer,
|
|
DrawerTrigger,
|
|
Input,
|
|
SubmitButton,
|
|
} from '@/components/ui';
|
|
import { toast } from 'sonner';
|
|
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
|
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
|
import { StatusHistory } from '@/components/layout/status';
|
|
|
|
type StatusListProps = {
|
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
|
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
|
|
};
|
|
|
|
export const StatusList = ({
|
|
preloadedUser,
|
|
preloadedStatuses,
|
|
}: StatusListProps) => {
|
|
const user = usePreloadedQuery(preloadedUser);
|
|
const statuses = usePreloadedQuery(preloadedStatuses);
|
|
|
|
const { tvMode } = useTVMode();
|
|
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
|
|
const [selectAll, setSelectAll] = useState(false);
|
|
const [statusInput, setStatusInput] = useState('');
|
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
|
|
|
const bulkCreate = useMutation(api.statuses.bulkCreate);
|
|
|
|
const handleSelectUser = (id: Id<'users'>, e: React.MouseEvent) => {
|
|
setSelectedUserIds((prev) =>
|
|
prev.some((i) => i === id)
|
|
? prev.filter((prevId) => prevId !== id)
|
|
: [...prev, id],
|
|
);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectAll) setSelectedUserIds([]);
|
|
else setSelectedUserIds(statuses.map((s) => s.user.id));
|
|
setSelectAll(!selectAll);
|
|
};
|
|
|
|
const handleUpdateStatus = async () => {
|
|
const message = statusInput.trim();
|
|
setUpdatingStatus(true);
|
|
try {
|
|
if (message.length < 3 || message.length > 80)
|
|
throw new Error('Status must be between 3 & 80 characters');
|
|
if (selectedUserIds.length === 0 && user?.id)
|
|
await bulkCreate({ message, userIds: [user.id] });
|
|
await bulkCreate({ message, userIds: selectedUserIds });
|
|
toast.success('Status updated.');
|
|
setSelectedUserIds([]);
|
|
setSelectAll(false);
|
|
setStatusInput('');
|
|
} catch (error) {
|
|
toast.error(`Update failed. ${error as Error}`);
|
|
} finally {
|
|
setUpdatingStatus(false);
|
|
}
|
|
};
|
|
|
|
const containerCn = ccn({
|
|
context: tvMode,
|
|
className:
|
|
'flex flex-col mx-auto items-center\
|
|
sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]',
|
|
on: 'mt-8',
|
|
off: 'px-10',
|
|
});
|
|
|
|
const headerCn = ccn({
|
|
context: tvMode,
|
|
className: 'w-full',
|
|
on: 'hidden',
|
|
off: 'flex mb-3 justify-between items-center',
|
|
});
|
|
|
|
const selectAllIconCn = ccn({
|
|
context: selectAll,
|
|
className: 'w-4 h-4',
|
|
on: 'text-green-500',
|
|
off: '',
|
|
});
|
|
|
|
const cardContainerCn = ccn({
|
|
context: tvMode,
|
|
className: 'w-full space-y-2',
|
|
on: 'text-primary',
|
|
off: '',
|
|
});
|
|
|
|
return (
|
|
<div className={containerCn}>
|
|
<div className={headerCn}>
|
|
<div className='flex items-center gap-4'>
|
|
<Button
|
|
onClick={handleSelectAll}
|
|
variant={'outline'}
|
|
size={'sm'}
|
|
className='flex items-center gap2'
|
|
>
|
|
<CheckCircle2 className={selectAllIconCn} />
|
|
<p className={selectAll ? 'font-bold' : 'font-semibold'}>
|
|
{selectAll ? 'Unselect All' : 'Select All'}
|
|
</p>
|
|
</Button>
|
|
{!tvMode && (
|
|
<div className='flex items-center gap-2 text-xs'>
|
|
<span className='text-muted-foreground'>Miss the old table?</span>
|
|
<Link href='/table' className='font-medium hover:underline'>
|
|
Find it here!
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cardContainerCn}>
|
|
{statuses.map((status) => {
|
|
const { user: u, status: s } = status;
|
|
const isSelected = selectedUserIds.includes(u.id);
|
|
const isUpdatedByOther = !!s?.updatedBy;
|
|
return (
|
|
<Card
|
|
key={u.id}
|
|
className={`relative transition-all duration-200
|
|
cursor-pointer hover:shadow-md
|
|
${tvMode ? 'p-4' : 'p-3'}
|
|
${
|
|
isSelected
|
|
? 'ring-2 ring-primary bg-primary/5 shadow-md'
|
|
: 'hover:bg-muted/30'
|
|
}
|
|
`}
|
|
onClick={(e) => handleSelectUser(u.id, e)}
|
|
>
|
|
<CardContent className='p-2'>
|
|
<div className='flex items-start gap-3'>
|
|
<div className='flex-shrink-0 cursor-pointer
|
|
hover:opacity-80 transition-opacity'
|
|
>
|
|
<BasedAvatar
|
|
src={u.imageUrl}
|
|
fullName={u.name ?? 'Technician'}
|
|
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
|
/>
|
|
|
|
</div>
|
|
<div className='flex-1'>
|
|
<div className='flex items-start justify-between mb-2'>
|
|
<div>
|
|
<h3 className={`font-semibold cursor-pointer
|
|
hover:text-primary/80 truncate
|
|
${tvMode ? 'text-3xl' : 'text-2xl'}
|
|
`}
|
|
>
|
|
{u.name ?? 'Technician'}
|
|
</h3>
|
|
|
|
<div className={`pl-2 pr-15 pt-2
|
|
${tvMode ? 'text-2xl' : 'text-xl'}
|
|
`}
|
|
>
|
|
<p>{s?.message ?? 'No status yet.'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Drawer>
|
|
<DrawerTrigger asChild>
|
|
<div
|
|
className='flex flex-col items-end px-2 gap-2
|
|
text-muted-foreground flex-shrink-0'
|
|
>
|
|
<div className='flex items-center gap-2'>
|
|
<Clock className={tvMode ? 'w-6 h-6' : 'w-5 h-5'} />
|
|
<span className={tvMode ? 'text-2xl' : 'text-xl'}>
|
|
{s ? formatTime(s.updatedAt) : '--:--'}
|
|
</span>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<Calendar
|
|
className={tvMode ? 'w-6 h-6' : 'w-5 h-5'}
|
|
/>
|
|
<span className={tvMode ? 'text-2xl' : 'text-xl'}>
|
|
{s ? formatDate(s.updatedAt) : '--/--'}
|
|
</span>
|
|
</div>
|
|
|
|
{isUpdatedByOther && s.updatedBy && (
|
|
<div className='flex items-center gap-2'>
|
|
<BasedAvatar
|
|
src={s.updatedBy.imageUrl}
|
|
fullName={s.updatedBy.name ?? 'User'}
|
|
className={tvMode ? 'w-6 h-6' : 'w-5 h-5'}
|
|
/>
|
|
<span className={tvMode ? 'text-base' : 'text-sm'}>
|
|
<div className='flex flex-col'>
|
|
<p>Updated by</p>
|
|
{s.updatedBy.name ?? 'User'}
|
|
</div>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DrawerTrigger>
|
|
<StatusHistory user={u} />
|
|
</Drawer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{statuses.length === 0 && (
|
|
<Card className='p-8 text-center'>
|
|
<p
|
|
className={`text-muted-foreground ${tvMode ? 'text-2xl' : 'text-lg'}`}
|
|
>
|
|
No status updates have been made in the past day.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
|
|
{!tvMode && (
|
|
<Card className='p-6 mt-4 w-full'>
|
|
<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 update...'
|
|
className='flex-1 text-2xl'
|
|
value={statusInput}
|
|
disabled={updatingStatus}
|
|
onChange={(e) => setStatusInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) {
|
|
e.preventDefault();
|
|
void handleUpdateStatus();
|
|
}
|
|
}}
|
|
/>
|
|
<SubmitButton
|
|
onClick={handleUpdateStatus}
|
|
disabled={updatingStatus}
|
|
className='px-6'
|
|
>
|
|
{selectedUserIds.length > 0
|
|
? `Update ${selectedUserIds.length}
|
|
${selectedUserIds.length > 1
|
|
? 'users'
|
|
: 'user'
|
|
}`
|
|
: 'Update Status'}
|
|
</SubmitButton>
|
|
</div>
|
|
</div>
|
|
<div className='flex justify-center mt-2'>
|
|
<Drawer>
|
|
<DrawerTrigger asChild>
|
|
<Button
|
|
variant='outline'
|
|
className={tvMode ? 'text-xl p-6' : ''}
|
|
>
|
|
View Status History
|
|
</Button>
|
|
</DrawerTrigger>
|
|
<StatusHistory />
|
|
</Drawer>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|