317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import {
|
|
BriefcaseBusiness,
|
|
CalendarClock,
|
|
ChevronDown,
|
|
Images,
|
|
ListChecks,
|
|
} from 'lucide-react';
|
|
|
|
import { PersonInfo } from '@/components/person-avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
|
import { cn } from '@/lib/utils';
|
|
import type { TicketActor, TicketAssignmentSummary } from '@/types';
|
|
import { formatDate } from '@/utils/date';
|
|
|
|
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
|
|
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
|
|
|
interface TicketAssignmentsCardProps {
|
|
ticketId: string;
|
|
videoId?: string | null;
|
|
initialAssignments?: TicketAssignmentSummary[];
|
|
selectedAssignmentId?: number;
|
|
onAssignmentChange: (assignmentId: number) => void;
|
|
}
|
|
|
|
function formatLabel(value: string) {
|
|
return value
|
|
.replaceAll('_', ' ')
|
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function AssignmentClassBadge({ className }: { className: string }) {
|
|
const visual = getDefectVisual(className);
|
|
const IssueIcon = visual.icon;
|
|
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-6 gap-1.5 px-2 font-normal"
|
|
style={{
|
|
borderColor: `${visual.boundingBoxColor}66`,
|
|
backgroundColor: `${visual.boundingBoxColor}14`,
|
|
color: visual.boundingBoxColor,
|
|
}}
|
|
>
|
|
<IssueIcon />
|
|
{formatLabel(className)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function AssignmentProgress({
|
|
assignment,
|
|
}: {
|
|
assignment: TicketAssignmentSummary;
|
|
}) {
|
|
const total = Math.max(assignment.item_count, 0);
|
|
const approved = Math.max(assignment.approved_detections, 0);
|
|
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
<span className="font-medium text-foreground">Repair progress</span>
|
|
<span className="tabular-nums text-muted-foreground">
|
|
{approved} of {total} approved
|
|
</span>
|
|
</div>
|
|
<Progress value={assignment.is_complete ? 100 : progress} />
|
|
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
|
|
<span className="whitespace-nowrap text-emerald-600">
|
|
{assignment.approved_detections} approved
|
|
</span>
|
|
<span className="whitespace-nowrap text-amber-600">
|
|
{assignment.pending_detections} pending
|
|
</span>
|
|
<span className="whitespace-nowrap text-muted-foreground">
|
|
{assignment.not_submitted_detections} not submitted
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssignmentOverview({
|
|
assignment,
|
|
}: {
|
|
assignment: TicketAssignmentSummary;
|
|
}) {
|
|
const criteria = assignment.criteria;
|
|
const classNames = criteria?.class_names?.length
|
|
? criteria.class_names
|
|
: assignment.defect_class
|
|
? [assignment.defect_class]
|
|
: [];
|
|
const worker: TicketActor = {
|
|
user_id: assignment.assigned_to_user_id,
|
|
name: assignment.assigned_to_name,
|
|
email: assignment.assigned_to_email ?? null,
|
|
avatar_url: assignment.assigned_to_avatar_url ?? null,
|
|
};
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
|
|
<div className="min-w-0">
|
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
|
Assignment {assignment.id}
|
|
</p>
|
|
<PersonInfo person={worker} />
|
|
</div>
|
|
|
|
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
|
{classNames.map((className) => (
|
|
<AssignmentClassBadge key={className} className={className} />
|
|
))}
|
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<ListChecks className="size-3.5" />
|
|
{assignment.item_count}{' '}
|
|
{assignment.item_count === 1 ? 'issue' : 'issues'}
|
|
</span>
|
|
{assignment.due_at ? (
|
|
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
|
|
<CalendarClock className="size-3.5" />
|
|
Due {formatDate(assignment.due_at)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<AssignmentProgress assignment={assignment} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TicketAssignmentsCard({
|
|
ticketId,
|
|
videoId,
|
|
initialAssignments,
|
|
selectedAssignmentId,
|
|
onAssignmentChange,
|
|
}: TicketAssignmentsCardProps) {
|
|
const assignmentsQuery = useTicketAssignmentsQuery(
|
|
ticketId,
|
|
initialAssignments,
|
|
);
|
|
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
|
(assignment) => assignment.item_count > 0,
|
|
);
|
|
const selectedAssignment =
|
|
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
|
assignments[0];
|
|
const activeAssignmentId = selectedAssignment
|
|
? String(selectedAssignment.id)
|
|
: '';
|
|
const resolvedAssignmentId = selectedAssignment?.id;
|
|
|
|
useEffect(() => {
|
|
if (
|
|
resolvedAssignmentId !== undefined &&
|
|
resolvedAssignmentId !== selectedAssignmentId
|
|
) {
|
|
onAssignmentChange(resolvedAssignmentId);
|
|
}
|
|
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
|
|
|
|
return (
|
|
<Card className="min-w-0 max-w-full">
|
|
<CardHeader className="min-w-0 px-3 sm:px-6">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BriefcaseBusiness className="size-5 text-primary" />
|
|
Work Assignments
|
|
</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
Issues grouped by assignee, scope, and repair progress.
|
|
</CardDescription>
|
|
</div>
|
|
{assignmentsQuery.data ? (
|
|
<Badge variant="default">
|
|
{assignments.length}{' '}
|
|
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
|
|
{assignmentsQuery.isLoading ? (
|
|
Array.from({ length: 2 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="h-40 animate-pulse rounded-lg bg-muted"
|
|
/>
|
|
))
|
|
) : assignmentsQuery.isError ? (
|
|
<div className="rounded-lg border border-dashed p-6 text-center">
|
|
<p className="text-sm font-medium">
|
|
Unable to load work assignments.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3"
|
|
onClick={() => void assignmentsQuery.refetch()}
|
|
>
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
) : selectedAssignment ? (
|
|
<div className="min-w-0 space-y-3">
|
|
<nav
|
|
aria-label="Work assignments"
|
|
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
|
|
>
|
|
<div
|
|
role="tablist"
|
|
aria-orientation="horizontal"
|
|
className="flex w-max min-w-full border-b"
|
|
>
|
|
{assignments.map((assignment) => {
|
|
const assignmentId = String(assignment.id);
|
|
const isActive = assignmentId === activeAssignmentId;
|
|
|
|
return (
|
|
<button
|
|
key={assignment.id}
|
|
id={`assignment-tab-${assignmentId}`}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`assignment-panel-${assignmentId}`}
|
|
tabIndex={isActive ? 0 : -1}
|
|
className={cn(
|
|
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
|
isActive
|
|
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
onClick={() => onAssignmentChange(assignment.id)}
|
|
>
|
|
Assignment {assignment.id}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
<div
|
|
id={`assignment-panel-${activeAssignmentId}`}
|
|
role="tabpanel"
|
|
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
|
|
className="min-w-0"
|
|
>
|
|
<AssignmentOverview assignment={selectedAssignment} />
|
|
{videoId ? (
|
|
<Collapsible
|
|
key={selectedAssignment.id}
|
|
className="group mt-4 overflow-hidden rounded-lg border"
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="h-auto w-full justify-between rounded-none px-4 py-3"
|
|
>
|
|
<span className="flex items-center gap-2 font-medium">
|
|
<Images className="size-4 text-primary" />
|
|
Assigned issue images
|
|
</span>
|
|
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="border-t p-4">
|
|
<TicketDetectionPreview
|
|
key={`${videoId}:${selectedAssignment.id}`}
|
|
ticketId={ticketId}
|
|
videoId={videoId}
|
|
assignmentId={selectedAssignment.id}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
|
<p className="text-sm font-medium">No work assignments yet</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Work assignments will appear here after issues are allocated.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|