9 Commits

12 changed files with 258 additions and 150 deletions

View File

@@ -67,7 +67,7 @@ export function PlanSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full gap-0 p-0 sm:max-w-3xl"> <SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12"> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight"> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{planId ? 'Edit Plan' : 'Create Plan'} {planId ? 'Edit Plan' : 'Create Plan'}
@@ -77,7 +77,11 @@ export function PlanSheet({
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4"> <div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField

View File

@@ -62,7 +62,7 @@ export function RoleSheet({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" side="right"
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl" className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
> >
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12"> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight"> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
@@ -73,7 +73,11 @@ export function RoleSheet({
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4"> <div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField

View File

@@ -1,85 +1,71 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
function TimelineEntrySkeleton({
index,
isLast,
}: {
index: number;
isLast: boolean;
}) {
return (
<TimelineItem
layout="left"
isLast={isLast}
opposite={
<div className="space-y-2">
<Skeleton className="ml-auto h-4 w-16" />
<Skeleton className="ml-auto h-3 w-12" />
</div>
}
marker={<Skeleton className="size-4 rounded-full" />}
>
<div
className={
index === 0
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
: 'rounded-lg border bg-card p-4'
}
>
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
{index === 0 ? (
<Skeleton className="mt-2 h-3 w-full max-w-md" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48 max-w-full" />
</div>
</div>
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
</>
)}
</div>
</TimelineItem>
);
}
export function TicketClassContentSkeleton() { export function TicketClassContentSkeleton() {
return ( return (
<div className="space-y-5" aria-label="Loading defect class details"> <div className="space-y-4" aria-label="Loading defect class details">
<Card> <div className="flex items-center gap-2">
<CardHeader className="sm:grid-cols-[1fr_auto]"> <Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-36" /> <Skeleton className="h-5 w-56 max-w-full" />
<Skeleton className="h-3 w-32 sm:col-start-2 sm:row-start-1" /> </div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-36" />
</div>
))}
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<div className="rounded-lg border bg-muted/15 px-4 py-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
</div>
</CardContent>
</Card>
<Card> <Timeline>
<CardHeader> {Array.from({ length: 3 }).map((_, index) => (
<Skeleton className="h-5 w-40" /> <TimelineEntrySkeleton
</CardHeader> key={index}
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2"> index={index}
<Skeleton className="aspect-video w-full rounded-lg" /> isLast={index === 2}
<Skeleton className="aspect-video w-full rounded-lg" /> />
</CardContent> ))}
</Card> </Timeline>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="space-y-2 rounded-lg border p-2">
<Skeleton className="aspect-4/3 w-full rounded-lg" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56" />
</div>
</CardHeader>
<CardContent>
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="relative flex gap-4 pb-8 last:pb-0">
{index < 2 ? (
<span className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border" />
) : null}
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-3 w-56" />
<Skeleton className="h-12 w-full" />
</div>
</div>
))}
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -137,11 +137,8 @@ export function TicketClassDetectionPreview({
} }
}, [currentIndex, detectionCount]); }, [currentIndex, detectionCount]);
const isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading; const isNavigationDisabled =
const isNextDisabled = detectionCount === 0 || detectionsQuery.isLoading;
detectionCount === 0 ||
currentIndex + 1 >= detectionCount ||
detectionsQuery.isLoading;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -157,9 +154,7 @@ export function TicketClassDetectionPreview({
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 > <h3>{displayName}</h3>
{displayName}
</h3>
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
@@ -181,8 +176,14 @@ export function TicketClassDetectionPreview({
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))} onClick={() =>
disabled={isPreviousDisabled} setCurrentIndex((index) =>
detectionCount === 0
? 0
: (index - 1 + detectionCount) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Previous detection" aria-label="Previous detection"
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
@@ -195,8 +196,12 @@ export function TicketClassDetectionPreview({
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => setCurrentIndex((index) => index + 1)} onClick={() =>
disabled={isNextDisabled} setCurrentIndex((index) =>
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Next detection" aria-label="Next detection"
> >
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
@@ -231,9 +236,7 @@ export function TicketClassDetectionPreview({
<> <>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<p className="text-muted-foreground"> <p className="text-muted-foreground">Detection Image</p>
Detection Image
</p>
<div className="overflow-hidden rounded-lg"> <div className="overflow-hidden rounded-lg">
<AnnotatedDetectionImage <AnnotatedDetectionImage
detection={activeDetection} detection={activeDetection}

View File

@@ -1,11 +1,29 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import {
Card,
CardAction,
CardContent,
CardHeader,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
function OverviewSkeletonField() { function OverviewMetaSkeletonField({ className }: { className?: string }) {
return ( return (
<div className="space-y-2"> <div className={`flex items-center gap-3 ${className ?? ''}`}>
<Skeleton className="h-3 w-24" /> <Skeleton className="size-5 shrink-0 rounded-lg" />
<Skeleton className="h-4 w-32" /> <div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32 max-w-full" />
</div>
</div>
);
}
function SummarySkeletonCard() {
return (
<div className="rounded-lg bg-muted/40 p-4">
<Skeleton className="h-7 w-10" />
<Skeleton className="mt-3 h-3 w-24 max-w-full" />
<Skeleton className="mt-2 h-3 w-16" />
</div> </div>
); );
} }
@@ -27,15 +45,45 @@ export function TicketOverviewHeaderSkeleton() {
export function TicketOverviewCardSkeleton() { export function TicketOverviewCardSkeleton() {
return ( return (
<Card aria-label="Loading ticket overview"> <div className="space-y-5" aria-label="Loading ticket overview">
<CardHeader> <Card>
<Skeleton className="h-4 w-20" /> <CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
</CardHeader> <OverviewMetaSkeletonField className="sm:pr-4" />
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4"> <OverviewMetaSkeletonField className="sm:px-4" />
{Array.from({ length: 4 }).map((_, index) => ( <OverviewMetaSkeletonField className="sm:pl-4" />
<OverviewSkeletonField key={index} /> </CardContent>
))} </Card>
</CardContent>
</Card> <Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-40" />
</div>
<CardAction>
<Skeleton className="h-9 w-28" />
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<SummarySkeletonCard key={index} />
))}
</div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex items-center justify-center gap-2"
>
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction'; import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction'; import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
@@ -41,7 +42,10 @@ function getTicketAction(ticket: TicketDetail) {
</PermissionGuard> </PermissionGuard>
} }
> >
<ReviewExtensionRequestAction ticket={ticket} /> <>
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} />
</>
</PermissionGuard> </PermissionGuard>
); );
} }

View File

@@ -27,7 +27,7 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
return date; return date;
}, []); }, []);
const maxDueMonth = useMemo( const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 5, 11), () => new Date(today.getFullYear() + 20, 11),
[today], [today],
); );

View File

@@ -0,0 +1,61 @@
'use client';
import { CalendarClock, UserCheck } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketActionCard } from './TicketActionCard';
function AssignmentField({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate text-sm font-medium text-foreground">{value}</p>
</div>
);
}
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
const worker = ticket.worker;
return (
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
{worker ? (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<PersonInfo person={worker} size="lg" />
<Badge>
Assigned
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
<AssignmentField
label="Assigned By"
value={worker.assigned_by_name ?? '-'}
/>
<AssignmentField
label="Assigned On"
value={formatDate(worker.assigned_at)}
/>
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">Due Date</p>
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{formatDate(worker.due_at)}</span>
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Assignment details are not available.
</p>
)}
</TicketActionCard>
);
}

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Clock3, Loader2, Send } from 'lucide-react'; import { Clock3, Loader2, Send } from 'lucide-react';
import { DatePicker } from '@/components/form/DatePicker'; import { DatePicker } from '@/components/form/DatePicker';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -22,9 +23,21 @@ export function RequestExtensionForm({
ticket, ticket,
onCancel, onCancel,
}: RequestExtensionFormProps) { }: RequestExtensionFormProps) {
const currentDueDate = ticket.worker?.due_at const currentDueAt = ticket.worker?.due_at;
? new Date(ticket.worker.due_at) const currentDueDate = useMemo(
: undefined; () => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt],
);
const earliestRequestedDate = useMemo(() => {
const date = currentDueDate ? new Date(currentDueDate) : new Date();
date.setHours(0, 0, 0, 0);
if (currentDueDate) date.setDate(date.getDate() + 1);
return date;
}, [currentDueDate]);
const latestRequestedMonth = useMemo(
() => new Date(earliestRequestedDate.getFullYear() + 20, 11),
[earliestRequestedDate],
);
const [requestedDueDate, setRequestedDueDate] = useState<Date>(); const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const assignmentId = ticket.worker?.assignment_id; const assignmentId = ticket.worker?.assignment_id;
@@ -81,12 +94,17 @@ export function RequestExtensionForm({
<Label> <Label>
Requested Due Date <span className="text-destructive">*</span> Requested Due Date <span className="text-destructive">*</span>
</Label> </Label>
<DatePicker <DatePickerSimple
id="requested-due-date"
value={requestedDueDate} value={requestedDueDate}
onChange={setRequestedDueDate} onChange={setRequestedDueDate}
showTimeZone={false} showLabel={false}
placeholder="Select requested due date"
disabled={isPending} disabled={isPending}
className="w-full" className="w-full"
startMonth={earliestRequestedDate}
endMonth={latestRequestedMonth}
disabledDates={{ before: earliestRequestedDate }}
/> />
{requestedDueDate && !isRequestedDateValid ? ( {requestedDueDate && !isRequestedDateValid ? (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">

View File

@@ -24,7 +24,6 @@ import {
useReviewTicketExtensionMutation, useReviewTicketExtensionMutation,
useTicketExtensionRequestQuery, useTicketExtensionRequestQuery,
} from '../../../hooks/useTicketQueries'; } from '../../../hooks/useTicketQueries';
import { NoTicketAction } from './NoTicketAction';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
const MAX_NOTE_LENGTH = 300; const MAX_NOTE_LENGTH = 300;
@@ -208,7 +207,7 @@ export function ReviewExtensionRequestAction({
); );
if (!assignmentId || extensionRequestQuery.isError) { if (!assignmentId || extensionRequestQuery.isError) {
return <NoTicketAction />; return null;
} }
if (extensionRequestQuery.isLoading) { if (extensionRequestQuery.isLoading) {
@@ -226,7 +225,7 @@ export function ReviewExtensionRequestAction({
? getPendingRequest(extensionRequestQuery.data) ? getPendingRequest(extensionRequestQuery.data)
: null; : null;
if (!pendingRequest) return <NoTicketAction />; if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />; return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
} }

View File

@@ -1,40 +1,21 @@
'use client'; 'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function ModeToggle() { export function ModeToggle() {
const { setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return ( return (
<DropdownMenu> <Button variant="outline" size="icon" onClick={toggleTheme}>
<DropdownMenuTrigger asChild> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Button variant="outline" size="icon"> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <span className="sr-only">Toggle theme</span>
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> </Button>
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

View File

@@ -73,7 +73,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", "group/dropdown-menu-item relative flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className className
)} )}
{...props} {...props}