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 { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
import type { TicketDetail, TicketOverviewDetail } from '@/types';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({
ticket,
classDetail,
}: {
ticket: TicketOverviewDetail;
classDetail?: TicketDetail;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
@@ -26,6 +29,11 @@ export function TicketDetailHeader({
</div>
<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}>
<AssignTicketAction ticketId={ticket.id} />
</PermissionGuard>

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,16 +77,17 @@ function Requester({ request }: { request: TicketExtensionRequest }) {
function ExtensionReviewForm({
ticket,
assignmentId,
request,
}: {
ticket: TicketDetail;
assignmentId: number;
request: TicketExtensionRequest;
}) {
const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null
>(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation(
ticket.id,
assignmentId,
@@ -197,16 +198,17 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({
ticket,
assignmentId,
}: {
ticket: TicketDetail;
assignmentId: number;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId || extensionRequestQuery.isError) {
if (extensionRequestQuery.isError) {
return null;
}
@@ -227,5 +229,11 @@ export function ReviewExtensionRequestAction({
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({
ticket,
assignmentId,
dueAt,
}: {
ticket: TicketDetail;
assignmentId: number;
dueAt: string | null;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
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) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
@@ -237,7 +232,13 @@ export function TicketExtensionRequestPanel({
: null;
if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} />;
return (
<RequestExtensionForm
ticket={ticket}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
);
}
return <ExtensionRequestDetails request={latestRequest} />;

View File

@@ -23,6 +23,7 @@ import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import {
useTicketAssignmentsQuery,
useTicketClassDetailQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
@@ -35,11 +36,18 @@ export default function TicketDetailPage() {
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const assignmentsQuery = useTicketAssignmentsQuery(
ticketId,
overview?.assignments,
);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const classDetail = classDetailQuery.data;
const activeAssignmentId =
selectedAssignmentId ?? overview?.assignments?.[0]?.id;
const assignments = assignmentsQuery.data?.items ?? [];
const activeAssignmentId = selectedAssignmentId ?? assignments[0]?.id;
const activeAssignment = assignments.find(
(assignment) => assignment.id === activeAssignmentId,
);
const isClassDetailLoading = Boolean(
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">
<div className="xl:shrink-0">
{overview ? (
<TicketDetailHeader ticket={overview} />
<TicketDetailHeader ticket={overview} classDetail={classDetail} />
) : (
<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">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
<TicketStatusActions
ticket={classDetail}
assignment={activeAssignment}
/>
) : null}
</div>
</div>

View File

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