feat(ticket): scope issue preview to selected assignment lot

This commit is contained in:
2026-07-29 18:43:22 +05:30
parent 5e66b70611
commit e8f2955acf
9 changed files with 121 additions and 68 deletions

View File

@@ -6,14 +6,17 @@ import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types'; import type { TicketDetail, TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction'; import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({ export function TicketDetailHeader({
ticket, ticket,
classDetail,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
classDetail?: TicketDetail;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id; const ticketLabel = ticket.ticket_name || ticket.id;
@@ -26,6 +29,11 @@ export function TicketDetailHeader({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{classDetail?.assignment_status === 'under_review' ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<OpenRepairReviewAction ticket={classDetail} />
</PermissionGuard>
) : null}
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} /> <AssignTicketAction ticketId={ticket.id} />
</PermissionGuard> </PermissionGuard>

View File

@@ -2,25 +2,24 @@
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types'; import type { TicketAssignmentSummary, TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction'; import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction'; import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction'; import { NoTicketAction } from './actions/NoTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
import { RequestExtensionAction } from './actions/RequestExtensionAction'; import { RequestExtensionAction } from './actions/RequestExtensionAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction'; import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
interface TicketStatusActionsProps { interface TicketStatusActionsProps {
ticket: TicketDetail; ticket: TicketDetail;
assignment?: TicketAssignmentSummary;
} }
function getTicketAction(ticket: TicketDetail) { function getTicketAction(
if (ticket.assignment_status === 'unassigned') { ticket: TicketDetail,
return null; assignment?: TicketAssignmentSummary,
} ) {
if (assignment && !assignment.is_complete) {
if (ticket.assignment_status === 'assigned') {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN} permissions={PERMISSIONS.TICKET.ASSIGN}
@@ -29,29 +28,46 @@ function getTicketAction(ticket: TicketDetail) {
permissions={PERMISSIONS.TICKET.WORK} permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<RequestExtensionAction ticket={ticket} /> <RequestExtensionAction
ticket={ticket}
assignmentId={assignment.id}
dueAt={assignment.due_at}
/>
</PermissionGuard> </PermissionGuard>
} }
> >
<> <>
<AssignedContractorAction ticket={ticket} /> {ticket.assignment_status === 'assigned' ? (
<ReviewExtensionRequestAction ticket={ticket} /> <AssignedContractorAction ticket={ticket} />
) : null}
<ReviewExtensionRequestAction
ticket={ticket}
assignmentId={assignment.id}
/>
</> </>
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'under_review') { if (ticket.assignment_status === 'unassigned') {
return null;
}
if (ticket.assignment_status === 'assigned') {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />} fallback={<NoTicketAction />}
> >
<OpenRepairReviewAction ticket={ticket} /> <AssignedContractorAction ticket={ticket} />
</PermissionGuard> </PermissionGuard>
); );
} }
if (ticket.assignment_status === 'under_review') {
return null;
}
if ( if (
ticket.assignment_status === 'approved' || ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected' ticket.assignment_status === 'rejected'
@@ -62,8 +78,11 @@ function getTicketAction(ticket: TicketDetail) {
return <NoTicketAction />; return <NoTicketAction />;
} }
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) { export function TicketStatusActions({
const action = getTicketAction(ticket); ticket,
assignment,
}: TicketStatusActionsProps) {
const action = getTicketAction(ticket, assignment);
return action ? <div className="space-y-3">{action}</div> : null; return action ? <div className="space-y-3">{action}</div> : null;
} }

View File

@@ -1,37 +1,24 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowRight, ClipboardCheck } from 'lucide-react'; import { ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { TicketDetail } from '@/types'; import type { TicketDetail } from '@/types';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { TicketActionCard } from './TicketActionCard';
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) { export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
const router = useRouter(); const router = useRouter();
return ( return (
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs"> <Button
<div className="space-y-4"> type="button"
<p className="text-sm leading-relaxed text-muted-foreground"> onClick={() =>
Review every submitted repair separately using its detection image, router.push(ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class))
location, and repair proof. }
</p> >
<Button <ClipboardCheck />
type="button" Review Repairs
className="w-full" </Button>
onClick={() =>
router.push(
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
)
}
>
Open Issue Review
<ArrowRight />
</Button>
</div>
</TicketActionCard>
); );
} }

View File

@@ -5,10 +5,22 @@ import type { TicketDetail } from '@/types';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel'; import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) { export function RequestExtensionAction({
ticket,
assignmentId,
dueAt,
}: {
ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) {
return ( return (
<TicketActionCard icon={Clock3} title="Request Extension"> <TicketActionCard icon={Clock3} title="Request Extension">
<TicketExtensionRequestPanel ticket={ticket} /> <TicketExtensionRequestPanel
ticket={ticket}
assignmentId={assignmentId}
dueAt={dueAt}
/>
</TicketActionCard> </TicketActionCard>
); );
} }

View File

@@ -16,10 +16,15 @@ const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps { interface RequestExtensionFormProps {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
currentDueAt: string | null;
} }
export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) { export function RequestExtensionForm({
const currentDueAt = ticket.worker?.due_at; ticket,
assignmentId,
currentDueAt,
}: RequestExtensionFormProps) {
const currentDueDate = useMemo( const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined), () => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt], [currentDueAt],
@@ -36,7 +41,6 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
); );
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 requestExtensionMutation = useRequestTicketExtensionMutation( const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id, ticket.id,
assignmentId, assignmentId,
@@ -48,10 +52,7 @@ export function RequestExtensionForm({ ticket }: RequestExtensionFormProps) {
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()), (!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
); );
const canSubmit = const canSubmit =
Boolean(assignmentId) && isRequestedDateValid && Boolean(reason.trim()) && !isPending;
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return; if (!requestedDueDate || !canSubmit) return;

View File

@@ -77,16 +77,17 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
function ExtensionReviewForm({ function ExtensionReviewForm({
ticket, ticket,
assignmentId,
request, request,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
request: TicketExtensionRequest; request: TicketExtensionRequest;
}) { }) {
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState< const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null 'approve' | 'reject' | null
>(null); >(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation( const reviewMutation = useReviewTicketExtensionMutation(
ticket.id, ticket.id,
assignmentId, assignmentId,
@@ -197,16 +198,17 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({ export function ReviewExtensionRequestAction({
ticket, ticket,
assignmentId,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
}) { }) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticket.id,
assignmentId, assignmentId,
); );
if (!assignmentId || extensionRequestQuery.isError) { if (extensionRequestQuery.isError) {
return null; return null;
} }
@@ -227,5 +229,11 @@ export function ReviewExtensionRequestAction({
if (!pendingRequest) return null; if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />; return (
<ExtensionReviewForm
ticket={ticket}
assignmentId={assignmentId}
request={pendingRequest}
/>
);
} }

View File

@@ -187,23 +187,18 @@ function ExtensionRequestDetails({
export function TicketExtensionRequestPanel({ export function TicketExtensionRequestPanel({
ticket, ticket,
assignmentId,
dueAt,
}: { }: {
ticket: TicketDetail; ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) { }) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id, ticket.id,
assignmentId, assignmentId,
); );
if (!assignmentId) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No active assignment is available for this ticket.
</div>
);
}
if (extensionRequestQuery.isLoading) { if (extensionRequestQuery.isLoading) {
return ( return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground"> <div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,7 +232,13 @@ export function TicketExtensionRequestPanel({
: null; : null;
if (!latestRequest) { if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />; return (
<RequestExtensionForm
ticket={ticket}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
} }
return <ExtensionRequestDetails request={latestRequest} />; return <ExtensionRequestDetails request={latestRequest} />;

View File

@@ -23,6 +23,7 @@ import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs'; import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery,
useTicketClassDetailQuery, useTicketClassDetailQuery,
useTicketOverviewQuery, useTicketOverviewQuery,
} from '../hooks/useTicketQueries'; } from '../hooks/useTicketQueries';
@@ -35,11 +36,18 @@ export default function TicketDetailPage() {
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>(); const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
overview?.assignments,
);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const classDetail = classDetailQuery.data; const classDetail = classDetailQuery.data;
const activeAssignmentId = const assignments = assignmentsQuery.data?.items ?? [];
selectedAssignmentId ?? overview?.assignments?.[0]?.id; const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
const activeAssignment = assignments.find(
(assignment) => assignment.id === activeAssignmentId,
);
const isClassDetailLoading = Boolean( const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading, defectClass && classDetailQuery.isLoading,
); );
@@ -66,7 +74,7 @@ export default function TicketDetailPage() {
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0"> <div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={overview} /> <TicketDetailHeader ticket={overview} classDetail={classDetail} />
) : ( ) : (
<TicketOverviewHeaderSkeleton /> <TicketOverviewHeaderSkeleton />
)} )}
@@ -101,7 +109,10 @@ export default function TicketDetailPage() {
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2"> <div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null} {isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? ( {!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} /> <TicketStatusActions
ticket={classDetail}
assignment={activeAssignment}
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -405,6 +405,9 @@ export function useRequestTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: ticketKeys.classDetail(ticketId, defectClass),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to request an extension'), onError: () => toast.error('Failed to request an extension'),
@@ -432,6 +435,9 @@ export function useReviewTicketExtensionMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass), queryKey: ticketKeys.classDetail(ticketId, defectClass),
}); });
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, },
onError: () => toast.error('Failed to review extension request'), onError: () => toast.error('Failed to review extension request'),