feat: assign base
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
10
opencode.json
Normal file
10
opencode.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"dual-graph": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "http://127.0.0.1:8080/mcp",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { TicketActor, TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
import {
|
|
||||||
TicketStatusBadge,
|
|
||||||
} from '../../components/TicketStatusBadge';
|
|
||||||
|
|
||||||
function formatCreatedDate(value: string | null | undefined) {
|
function formatCreatedDate(value: string | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
||||||
@@ -14,20 +10,6 @@ function formatCreatedDate(value: string | null | undefined) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(person: TicketActor | null | undefined) {
|
|
||||||
const source = person?.name || person?.email || '?';
|
|
||||||
return source
|
|
||||||
.split(/[\s@._-]+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part.charAt(0).toUpperCase())
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonLabel(person: TicketActor | null | undefined) {
|
|
||||||
return person?.name || person?.email || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
@@ -40,29 +22,6 @@ function MetaValue({ children }: { children: React.ReactNode }) {
|
|||||||
return <p className="text-sm font-semibold text-foreground">{children}</p>;
|
return <p className="text-sm font-semibold text-foreground">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PersonMetaValue({
|
|
||||||
person,
|
|
||||||
accentClassName = 'bg-primary/15 text-primary',
|
|
||||||
}: {
|
|
||||||
person: TicketActor | null | undefined;
|
|
||||||
accentClassName?: string;
|
|
||||||
}) {
|
|
||||||
if (!person) {
|
|
||||||
return <MetaValue>-</MetaValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClassName}`}
|
|
||||||
>
|
|
||||||
{getInitials(person)}
|
|
||||||
</span>
|
|
||||||
<p className="truncate text-sm font-medium">{getPersonLabel(person)}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewField({
|
function OverviewField({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
@@ -80,6 +39,22 @@ function OverviewField({
|
|||||||
|
|
||||||
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
const detectionCount = ticket.ai_result?.detection_count;
|
const detectionCount = ticket.ai_result?.detection_count;
|
||||||
|
const assignments = ticket.assignments ?? [];
|
||||||
|
const primaryAssignment = assignments[0] ?? null;
|
||||||
|
const assignedNames = Array.from(
|
||||||
|
new Set(
|
||||||
|
assignments
|
||||||
|
.map((assignment) => assignment.assigned_to_name)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const reviewerNames = Array.from(
|
||||||
|
new Set(
|
||||||
|
assignments
|
||||||
|
.map((assignment) => assignment.reviewed_by_name)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border bg-card">
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
@@ -101,7 +76,9 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
<OverviewField label="Uploaded By">
|
<OverviewField label="Uploaded By">
|
||||||
<PersonMetaValue person={ticket.uploader} />
|
<MetaValue>
|
||||||
|
{ticket.uploader?.name || ticket.uploader?.email || '-'}
|
||||||
|
</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
<OverviewField label="Created Date">
|
<OverviewField label="Created Date">
|
||||||
@@ -109,21 +86,27 @@ export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
<OverviewField label="Assigned To">
|
<OverviewField label="Assigned To">
|
||||||
<PersonMetaValue
|
<MetaValue>
|
||||||
person={ticket.worker}
|
{assignedNames.length ? assignedNames.join(', ') : '-'}
|
||||||
accentClassName="bg-secondary text-secondary-foreground"
|
</MetaValue>
|
||||||
/>
|
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
<OverviewField label="Reviewer">
|
<OverviewField label="Reviewer">
|
||||||
<PersonMetaValue
|
<MetaValue>
|
||||||
person={ticket.reviewer}
|
{reviewerNames.length ? reviewerNames.join(', ') : '-'}
|
||||||
accentClassName="bg-primary/20 text-primary"
|
</MetaValue>
|
||||||
/>
|
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
<OverviewField label="Assigned At">
|
<OverviewField label="Assigned At">
|
||||||
<MetaValue>{formatDate(ticket.worker?.assigned_at)}</MetaValue>
|
<MetaValue>{formatDate(primaryAssignment?.assigned_at)}</MetaValue>
|
||||||
|
</OverviewField>
|
||||||
|
|
||||||
|
<OverviewField label="Coverage">
|
||||||
|
<MetaValue>
|
||||||
|
{ticket.coverage
|
||||||
|
? `${ticket.coverage.assigned_count}/${ticket.coverage.total_count} Classes`
|
||||||
|
: '-'}
|
||||||
|
</MetaValue>
|
||||||
</OverviewField>
|
</OverviewField>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,12 +9,23 @@ import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
|||||||
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
||||||
|
|
||||||
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
const repair = ticket.repair;
|
const repairedAssignments = (ticket.assignments ?? []).filter(
|
||||||
|
(assignment) =>
|
||||||
|
assignment.repair_submitted_at ||
|
||||||
|
assignment.repair_notes ||
|
||||||
|
assignment.repair_proof_video_url ||
|
||||||
|
assignment.repair_proof_image_urls.length,
|
||||||
|
);
|
||||||
|
|
||||||
if (!repair) {
|
if (!repairedAssignments.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageUrls = repairedAssignments.flatMap(
|
||||||
|
(assignment) => assignment.repair_proof_image_urls,
|
||||||
|
);
|
||||||
|
const submittedAt = repairedAssignments[0]?.repair_submitted_at ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<RepairReportPanel ticket={ticket} />
|
<RepairReportPanel ticket={ticket} />
|
||||||
@@ -28,8 +39,8 @@ export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<RepairProofImagesGrid
|
<RepairProofImagesGrid
|
||||||
imageUrls={repair?.proof_image_urls ?? []}
|
imageUrls={imageUrls}
|
||||||
submittedAt={repair?.submitted_at ?? null}
|
submittedAt={submittedAt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ 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';
|
||||||
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
||||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
|
||||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
|
||||||
|
|
||||||
interface TicketStatusActionsProps {
|
interface TicketStatusActionsProps {
|
||||||
ticket: TicketDetail;
|
ticket: TicketDetail;
|
||||||
@@ -30,10 +28,18 @@ export function TicketStatusActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'unassigned') {
|
if (
|
||||||
|
ticket.status === 'unassigned' ||
|
||||||
|
ticket.status === 'assigned' ||
|
||||||
|
ticket.status === 'under_review'
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
permissions={[
|
||||||
|
PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
PERMISSIONS.TICKET.WORK,
|
||||||
|
PERMISSIONS.TICKET.REVIEW,
|
||||||
|
]}
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
fallback={<NoTicketAction status={ticket.status} />}
|
||||||
>
|
>
|
||||||
<AssignTicketAction ticket={ticket} />
|
<AssignTicketAction ticket={ticket} />
|
||||||
@@ -41,28 +47,6 @@ export function TicketStatusActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'assigned') {
|
|
||||||
return (
|
|
||||||
<PermissionGuard
|
|
||||||
permissions={PERMISSIONS.TICKET.WORK}
|
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
|
||||||
>
|
|
||||||
<SubmitRepairAction ticket={ticket} />
|
|
||||||
</PermissionGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticket.status === 'under_review') {
|
|
||||||
return (
|
|
||||||
<PermissionGuard
|
|
||||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
|
||||||
fallback={<NoTicketAction status={ticket.status} />}
|
|
||||||
>
|
|
||||||
<ReviewRepairAction ticket={ticket} />
|
|
||||||
</PermissionGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticket.status === 'closed') {
|
if (ticket.status === 'closed') {
|
||||||
return <ClosedTicketAction ticket={ticket} />;
|
return <ClosedTicketAction ticket={ticket} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,44 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Send, UserPlus } from 'lucide-react';
|
import { UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
import { TicketAssignmentSheet } from './TicketAssignmentSheet';
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
const [selectedUserId, setSelectedUserId] = useState('');
|
const [open, setOpen] = useState(false);
|
||||||
const [assignNote, setAssignNote] = useState('');
|
const coverage = ticket.coverage;
|
||||||
|
|
||||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TicketActionCard icon={UserPlus} title="Assign Ticket">
|
<TicketActionCard icon={UserPlus} title="Assignments">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
{coverage ? (
|
||||||
<Label>Contractor</Label>
|
<div className="rounded-lg border bg-muted/15 px-3 py-2">
|
||||||
<AssignableWorkerSelect
|
<p className="text-sm font-medium">
|
||||||
value={selectedUserId}
|
{coverage.assigned_count}/{coverage.total_count} classes assigned
|
||||||
onValueChange={setSelectedUserId}
|
</p>
|
||||||
disabled={assignMutation.isPending}
|
{coverage.unassigned_defect_classes.length ? (
|
||||||
/>
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
</div>
|
Still needs a contractor:{' '}
|
||||||
<div className="space-y-2">
|
{coverage.unassigned_defect_classes.join(', ')}
|
||||||
<Label>Note</Label>
|
</p>
|
||||||
<Textarea
|
) : null}
|
||||||
value={assignNote}
|
</div>
|
||||||
onChange={(event) => setAssignNote(event.target.value)}
|
) : null}
|
||||||
placeholder="Please inspect and repair"
|
<Button className="w-full" onClick={() => setOpen(true)}>
|
||||||
/>
|
<UserPlus />
|
||||||
</div>
|
Manage Assignments
|
||||||
<Button
|
|
||||||
disabled={!selectedUserId || assignMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedUserId) return;
|
|
||||||
assignMutation.mutate({
|
|
||||||
assigned_to_user_id: Number(selectedUserId),
|
|
||||||
note: assignNote || undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
Assign
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<TicketAssignmentSheet
|
||||||
|
ticket={ticket}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
/>
|
||||||
</TicketActionCard>
|
</TicketActionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketAssignmentSummary, TicketDetail } from '@/types';
|
||||||
|
|
||||||
function getInitials(name?: string | null, email?: string | null) {
|
function getInitials(name?: string | null, email?: string | null) {
|
||||||
const source = name || email || '?';
|
const source = name || email || '?';
|
||||||
@@ -25,8 +25,8 @@ function getInitials(name?: string | null, email?: string | null) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPersonLabel(person: TicketDetail['reviewer']) {
|
function getPersonLabel(assignment: TicketAssignmentSummary | null) {
|
||||||
return person?.name || person?.email || 'Unknown';
|
return assignment?.reviewed_by_name || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatReviewDate(value: string | null | undefined) {
|
function formatReviewDate(value: string | null | undefined) {
|
||||||
@@ -42,7 +42,8 @@ function getClosedAt(ticket: TicketDetail) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
closedEntry?.created_at ??
|
closedEntry?.created_at ??
|
||||||
ticket.reviewer?.reviewed_at ??
|
(ticket.assignments ?? []).find((assignment) => assignment.reviewed_at)
|
||||||
|
?.reviewed_at ??
|
||||||
ticket.timestamps.updated_at
|
ticket.timestamps.updated_at
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,8 +95,13 @@ function FinalCommentsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
const reviewer = ticket.reviewer;
|
const reviewer = (ticket.assignments ?? []).find(
|
||||||
|
(assignment) => assignment.reviewed_by_name || assignment.reviewed_at,
|
||||||
|
) ?? null;
|
||||||
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
|
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
|
||||||
|
const reviewComments = (ticket.assignments ?? [])
|
||||||
|
.map((assignment) => assignment.review_comment)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden border bg-card/80">
|
<Card className="relative overflow-hidden border bg-card/80">
|
||||||
@@ -112,16 +118,11 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Avatar className="size-10 border border-primary/30">
|
<Avatar className="size-10 border border-primary/30">
|
||||||
<AvatarFallback className="bg-primary/15 text-sm text-primary">
|
<AvatarFallback className="bg-primary/15 text-sm text-primary">
|
||||||
{getInitials(reviewer?.name, reviewer?.email)}
|
{getInitials(reviewer?.reviewed_by_name, null)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate">{getPersonLabel(reviewer)}</p>
|
<p className="truncate">{getPersonLabel(reviewer)}</p>
|
||||||
{reviewer?.email ? (
|
|
||||||
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
|
|
||||||
{reviewer.email}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
|||||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
Review Comment
|
Review Comment
|
||||||
</p>
|
</p>
|
||||||
<FinalCommentsPanel comment={reviewer?.review_comment} />
|
<FinalCommentsPanel comment={reviewComments.join('\n\n')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
import { useSubmitAssignmentRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
|
||||||
const MAX_IMAGES = 5;
|
const MAX_IMAGES = 20;
|
||||||
|
|
||||||
function RequiredMark() {
|
function RequiredMark() {
|
||||||
return <span className="text-destructive">*</span>;
|
return <span className="text-destructive">*</span>;
|
||||||
@@ -131,16 +131,23 @@ function ImageSlot({
|
|||||||
|
|
||||||
interface RepairEvidenceFormProps {
|
interface RepairEvidenceFormProps {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
|
assignmentId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
export function RepairEvidenceForm({
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
}: RepairEvidenceFormProps) {
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
const submitRepairUploadMutation = useSubmitAssignmentRepairUploadMutation(
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
);
|
||||||
const isSubmitting = submitRepairUploadMutation.isPending;
|
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||||
const canSubmit = Boolean(notes.trim() && videoFile);
|
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||||
|
|
||||||
@@ -261,7 +268,7 @@ export function RepairEvidenceForm({ ticketId }: RepairEvidenceFormProps) {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2 md:grid-cols-10">
|
||||||
{imageSlots.map((slot, index) => (
|
{imageSlots.map((slot, index) => (
|
||||||
<ImageSlot
|
<ImageSlot
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,63 +1,7 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Check, ClipboardCheck, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
import { AssignTicketAction } from './AssignTicketAction';
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
const [reviewComment, setReviewComment] = useState('');
|
return <AssignTicketAction ticket={ticket} />;
|
||||||
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
|
||||||
}, [ticket]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Comment</Label>
|
|
||||||
<Textarea
|
|
||||||
value={reviewComment}
|
|
||||||
onChange={(event) => setReviewComment(event.target.value)}
|
|
||||||
placeholder="Repair verified"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
action: 'approve',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Check />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!reviewComment || reviewRepairMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
reviewRepairMutation.mutate({
|
|
||||||
action: 'reject',
|
|
||||||
comment: reviewComment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Wrench } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
import { AssignTicketAction } from './AssignTicketAction';
|
||||||
import { TicketActionCard } from './TicketActionCard';
|
|
||||||
|
|
||||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
return (
|
return <AssignTicketAction ticket={ticket} />;
|
||||||
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
|
||||||
<RepairEvidenceForm ticketId={ticket.id} />
|
|
||||||
</TicketActionCard>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Check, ClipboardCheck, Loader2, Send, Wrench, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
|
import type {
|
||||||
|
AssignmentStatus,
|
||||||
|
TicketAssignmentSummary,
|
||||||
|
TicketDefectClass,
|
||||||
|
TicketDetail,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useReplaceTicketAssignmentsMutation,
|
||||||
|
useReviewAssignmentRepairMutation,
|
||||||
|
useTicketAssignmentsQuery,
|
||||||
|
useTicketDefectClassesQuery,
|
||||||
|
} from '../../../hooks/useTicketQueries';
|
||||||
|
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||||
|
|
||||||
|
interface TicketAssignmentSheetProps {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignmentRowState {
|
||||||
|
defectClass: string;
|
||||||
|
displayName: string;
|
||||||
|
selectedUserId: string;
|
||||||
|
note: string;
|
||||||
|
assignment: TicketAssignmentSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<AssignmentStatus, string> = {
|
||||||
|
assigned: 'border-blue-200 bg-blue-50 text-blue-700',
|
||||||
|
under_review: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
approved: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
rejected: 'border-red-200 bg-red-50 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_ASSIGNMENTS: TicketAssignmentSummary[] = [];
|
||||||
|
|
||||||
|
function titleCase(value: string) {
|
||||||
|
return value
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: AssignmentStatus | null | undefined) {
|
||||||
|
return status ? titleCase(status) : 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackClasses(ticket: TicketDetail): TicketDefectClass[] {
|
||||||
|
const assignments = ticket.assignments ?? [];
|
||||||
|
const names =
|
||||||
|
ticket.coverage?.total_defect_classes ??
|
||||||
|
ticket.ai_result?.available_defect_classes ??
|
||||||
|
assignments.map((assignment) => assignment.defect_class);
|
||||||
|
|
||||||
|
return names.map((className) => {
|
||||||
|
const existing = assignments.find(
|
||||||
|
(assignment) => assignment.defect_class === className,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
class_name: className,
|
||||||
|
display_name: existing?.defect_display_name ?? titleCase(className),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(
|
||||||
|
classes: TicketDefectClass[],
|
||||||
|
assignments: TicketAssignmentSummary[],
|
||||||
|
): AssignmentRowState[] {
|
||||||
|
const classMap = new Map<string, TicketDefectClass>();
|
||||||
|
classes.forEach((item) => classMap.set(item.class_name, item));
|
||||||
|
assignments.forEach((assignment) => {
|
||||||
|
if (!classMap.has(assignment.defect_class)) {
|
||||||
|
classMap.set(assignment.defect_class, {
|
||||||
|
class_name: assignment.defect_class,
|
||||||
|
display_name: assignment.defect_display_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(classMap.values()).map((item) => {
|
||||||
|
const assignment = assignments.find(
|
||||||
|
(current) => current.defect_class === item.class_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defectClass: item.class_name,
|
||||||
|
displayName: item.display_name,
|
||||||
|
selectedUserId: assignment?.assigned_to_user_id
|
||||||
|
? String(assignment.assigned_to_user_id)
|
||||||
|
: '',
|
||||||
|
note: assignment?.note ?? '',
|
||||||
|
assignment: assignment ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: AssignmentStatus | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (!status) return <Badge variant="outline">Unassigned</Badge>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={statusStyles[status]}>
|
||||||
|
{formatStatus(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentReviewControls({
|
||||||
|
ticketId,
|
||||||
|
assignment,
|
||||||
|
}: {
|
||||||
|
ticketId: string;
|
||||||
|
assignment: TicketAssignmentSummary;
|
||||||
|
}) {
|
||||||
|
const [comment, setComment] = useState(assignment.review_comment ?? '');
|
||||||
|
const reviewMutation = useReviewAssignmentRepairMutation(
|
||||||
|
ticketId,
|
||||||
|
assignment.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComment(assignment.review_comment ?? '');
|
||||||
|
}, [assignment.review_comment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/15 p-3">
|
||||||
|
<Label className="text-xs">Review Comment</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
placeholder="Repair verified"
|
||||||
|
className="min-h-20 resize-none"
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!comment.trim() || reviewMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
action: 'approve',
|
||||||
|
comment: comment.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!comment.trim() || reviewMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
action: 'reject',
|
||||||
|
comment: comment.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentProofSummary({
|
||||||
|
assignment,
|
||||||
|
}: {
|
||||||
|
assignment: TicketAssignmentSummary;
|
||||||
|
}) {
|
||||||
|
if (!assignment.repair_submitted_at && !assignment.review_comment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
|
||||||
|
{assignment.repair_submitted_at ? (
|
||||||
|
<div className="rounded-md border bg-muted/10 px-3 py-2">
|
||||||
|
<p className="font-medium text-foreground">Repair submitted</p>
|
||||||
|
<p>{assignment.repair_submitted_at}</p>
|
||||||
|
<p>
|
||||||
|
{assignment.repair_proof_video_url ? '1 video' : 'No video'} /{' '}
|
||||||
|
{assignment.repair_proof_image_urls.length} images
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{assignment.review_comment ? (
|
||||||
|
<div className="rounded-md border bg-muted/10 px-3 py-2">
|
||||||
|
<p className="font-medium text-foreground">Review comment</p>
|
||||||
|
<p className="line-clamp-2">{assignment.review_comment}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketAssignmentSheet({
|
||||||
|
ticket,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: TicketAssignmentSheetProps) {
|
||||||
|
const sheetContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const fallbackClasses = useMemo(() => getFallbackClasses(ticket), [ticket]);
|
||||||
|
const defectClassesQuery = useTicketDefectClassesQuery(ticket.id, open);
|
||||||
|
const assignmentsQuery = useTicketAssignmentsQuery(ticket.id, open);
|
||||||
|
const replaceAssignmentsMutation = useReplaceTicketAssignmentsMutation(
|
||||||
|
ticket.id,
|
||||||
|
);
|
||||||
|
const [rows, setRows] = useState<AssignmentRowState[]>([]);
|
||||||
|
|
||||||
|
const defectClasses = defectClassesQuery.data?.defect_classes.length
|
||||||
|
? defectClassesQuery.data.defect_classes
|
||||||
|
: fallbackClasses;
|
||||||
|
const assignments =
|
||||||
|
assignmentsQuery.data?.items ?? ticket.assignments ?? EMPTY_ASSIGNMENTS;
|
||||||
|
const coverage = ticket.coverage;
|
||||||
|
const isLoading = defectClassesQuery.isLoading || assignmentsQuery.isLoading;
|
||||||
|
const assignedRows = rows.filter((row) => row.selectedUserId);
|
||||||
|
const canSave =
|
||||||
|
assignedRows.length > 0 && !replaceAssignmentsMutation.isPending;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setRows(buildRows(defectClasses, assignments));
|
||||||
|
}, [assignments, defectClasses, open]);
|
||||||
|
|
||||||
|
const updateRow = (
|
||||||
|
defectClass: string,
|
||||||
|
patch: Partial<Pick<AssignmentRowState, 'selectedUserId' | 'note'>>,
|
||||||
|
) => {
|
||||||
|
setRows((current) =>
|
||||||
|
current.map((row) =>
|
||||||
|
row.defectClass === defectClass ? { ...row, ...patch } : row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await replaceAssignmentsMutation.mutateAsync({
|
||||||
|
assignments: assignedRows.map((row) => ({
|
||||||
|
assigned_to_user_id: Number(row.selectedUserId),
|
||||||
|
defect_class: row.defectClass,
|
||||||
|
note: row.note.trim() || undefined,
|
||||||
|
})),
|
||||||
|
note: 'Updated ticket assignments',
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
ref={sheetContentRef}
|
||||||
|
side="right"
|
||||||
|
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-4xl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
|
Manage Assignments
|
||||||
|
</SheetTitle>
|
||||||
|
{coverage ? (
|
||||||
|
<Badge
|
||||||
|
variant={coverage.is_fully_assigned ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{coverage.assigned_count}/{coverage.total_count} assigned
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SheetDescription className="text-sm">
|
||||||
|
Assign workers, submit repair evidence, and review each defect
|
||||||
|
class.
|
||||||
|
</SheetDescription>
|
||||||
|
{coverage?.unassigned_defect_classes.length ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Still needs a worker:{' '}
|
||||||
|
{coverage.unassigned_defect_classes.join(', ')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{isLoading && rows.length === 0 ? (
|
||||||
|
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
||||||
|
Loading assignments...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const assignment = row.assignment;
|
||||||
|
const canSubmitRepair =
|
||||||
|
assignment?.status === 'assigned' ||
|
||||||
|
assignment?.status === 'rejected';
|
||||||
|
const canReview = assignment?.status === 'under_review';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.defectClass}
|
||||||
|
className="space-y-4 rounded-lg border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(140px,1fr)_minmax(220px,1.2fr)_120px]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Defect Class
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{row.displayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Worker
|
||||||
|
</Label>
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||||
|
fallback={
|
||||||
|
<p className="rounded-md border px-3 py-2 text-sm">
|
||||||
|
{assignment?.assigned_to_name ?? 'Unassigned'}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AssignableWorkerSelect
|
||||||
|
value={row.selectedUserId}
|
||||||
|
onValueChange={(selectedUserId) =>
|
||||||
|
updateRow(row.defectClass, { selectedUserId })
|
||||||
|
}
|
||||||
|
disabled={replaceAssignmentsMutation.isPending}
|
||||||
|
portalContainer={sheetContentRef}
|
||||||
|
/>
|
||||||
|
</PermissionGuard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
|
<div className="flex h-9 items-center">
|
||||||
|
<AssignmentStatusBadge status={assignment?.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Note
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={row.note}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateRow(row.defectClass, {
|
||||||
|
note: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Optional assignment note"
|
||||||
|
className="min-h-20 resize-none"
|
||||||
|
disabled={replaceAssignmentsMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
|
||||||
|
{assignment ? (
|
||||||
|
<AssignmentProofSummary assignment={assignment} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{assignment && canSubmitRepair ? (
|
||||||
|
<PermissionGuard permissions={PERMISSIONS.TICKET.WORK}>
|
||||||
|
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Wrench className="size-4 text-primary" />
|
||||||
|
Submit repair for {row.displayName}
|
||||||
|
</p>
|
||||||
|
<RepairEvidenceForm
|
||||||
|
ticketId={ticket.id}
|
||||||
|
assignmentId={assignment.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{assignment && canReview ? (
|
||||||
|
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<ClipboardCheck className="size-4 text-primary" />
|
||||||
|
Review repair for {row.displayName}
|
||||||
|
</p>
|
||||||
|
<AssignmentReviewControls
|
||||||
|
ticketId={ticket.id}
|
||||||
|
assignment={assignment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!rows.length ? (
|
||||||
|
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No defect classes are available for this ticket.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
|
<Button type="button" disabled={!canSave} onClick={handleSave}>
|
||||||
|
{replaceAssignmentsMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
Save Assignments
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
<SheetClose asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={replaceAssignmentsMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</SheetClose>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FileText, MessageSquareQuote, BadgeCheck } from 'lucide-react';
|
import { FileText, MessageSquareQuote } from 'lucide-react';
|
||||||
|
|
||||||
import type { TicketDetail } from '@/types';
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmptyPanel,
|
EmptyPanel,
|
||||||
formatRepairDate,
|
formatRepairDate,
|
||||||
getPersonLabel,
|
|
||||||
} from './repairReportUtils';
|
} from './repairReportUtils';
|
||||||
|
|
||||||
function MetaLabel({ children }: { children: React.ReactNode }) {
|
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||||
@@ -34,9 +33,12 @@ function ReportField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
||||||
const repair = ticket.repair;
|
const repairs = (ticket.assignments ?? []).filter(
|
||||||
|
(assignment) => assignment.repair_submitted_at || assignment.repair_notes,
|
||||||
|
);
|
||||||
|
const firstRepair = repairs[0] ?? null;
|
||||||
|
|
||||||
if (!repair) {
|
if (!firstRepair) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-xl border bg-card p-4 lg:p-6">
|
<section className="space-y-4 rounded-xl border bg-card p-4 lg:p-6">
|
||||||
<div className="flex items-center gap-2 border-b border-border pb-4">
|
<div className="flex items-center gap-2 border-b border-border pb-4">
|
||||||
@@ -60,37 +62,46 @@ export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
|||||||
<FileText className="size-5 text-primary" />
|
<FileText className="size-5 text-primary" />
|
||||||
Repair Report
|
Repair Report
|
||||||
</h3>
|
</h3>
|
||||||
{repair.submitted_at ? (
|
{firstRepair.repair_submitted_at ? (
|
||||||
<span className="text-xs text-muted-foreground italic">
|
<span className="text-xs text-muted-foreground italic">
|
||||||
Completed {formatRepairDate(repair.submitted_at)}
|
Completed {formatRepairDate(firstRepair.repair_submitted_at)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<ReportField label="Technician" value={getPersonLabel(ticket.worker)} />
|
<ReportField
|
||||||
|
label="Submitted Classes"
|
||||||
|
value={`${repairs.length} of ${(ticket.assignments ?? []).length}`}
|
||||||
|
/>
|
||||||
|
<ReportField
|
||||||
|
label="Latest Contractor"
|
||||||
|
value={firstRepair.assigned_to_name ?? firstRepair.assigned_to_email}
|
||||||
|
/>
|
||||||
<ReportField
|
<ReportField
|
||||||
label="Submitted At"
|
label="Submitted At"
|
||||||
value={
|
value={formatRepairDate(firstRepair.repair_submitted_at)}
|
||||||
repair.submitted_at ? formatRepairDate(repair.submitted_at) : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<MetaLabel>Repair Summary</MetaLabel>
|
<MetaLabel>Repair Summary</MetaLabel>
|
||||||
{repair.notes?.trim() ? (
|
{repairs.some((repair) => repair.repair_notes?.trim()) ? (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
{repairs.map((repair) => (
|
||||||
<p className="text-sm leading-relaxed text-foreground/90">
|
<div
|
||||||
{repair.notes}
|
key={repair.id}
|
||||||
</p>
|
className="rounded-lg border bg-muted/15 px-4 py-3"
|
||||||
</div>
|
>
|
||||||
{/* <div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<BadgeCheck className="size-3.5 text-primary" />
|
{repair.defect_display_name}
|
||||||
<span>Self-certified by {getPersonLabel(ticket.worker)}</span>
|
</p>
|
||||||
</div> */}
|
<p className="mt-1 text-sm leading-relaxed text-foreground/90">
|
||||||
</>
|
{repair.repair_notes || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyPanel
|
<EmptyPanel
|
||||||
icon={MessageSquareQuote}
|
icon={MessageSquareQuote}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type { TicketDetail } from '@/types';
|
|||||||
|
|
||||||
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
||||||
const originalVideoUrl = ticket.video?.url ?? null;
|
const originalVideoUrl = ticket.video?.url ?? null;
|
||||||
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
|
const repairedAssignment = (ticket.assignments ?? []).find(
|
||||||
|
(assignment) => assignment.repair_proof_video_url,
|
||||||
|
);
|
||||||
|
const repairVideoUrl = repairedAssignment?.repair_proof_video_url ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -31,12 +34,12 @@ export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
|||||||
videoUrl={repairVideoUrl}
|
videoUrl={repairVideoUrl}
|
||||||
meta="Contractor evidence video"
|
meta="Contractor evidence video"
|
||||||
emptyTitle={
|
emptyTitle={
|
||||||
ticket.repair
|
repairedAssignment
|
||||||
? 'No repair video uploaded'
|
? 'No repair video uploaded'
|
||||||
: 'No evidence submitted yet'
|
: 'No evidence submitted yet'
|
||||||
}
|
}
|
||||||
emptyDescription={
|
emptyDescription={
|
||||||
ticket.repair
|
repairedAssignment
|
||||||
? 'The assigned worker has not uploaded a repair video yet.'
|
? 'The assigned worker has not uploaded a repair video yet.'
|
||||||
: 'Repair video will appear here once evidence is submitted.'
|
: 'Repair video will appear here once evidence is submitted.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import { TicketStatusBadge } from './TicketStatusBadge';
|
|||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||||
|
const getAssignedNames = (ticket: TicketListItem) => {
|
||||||
|
const names = Array.from(
|
||||||
|
new Set(
|
||||||
|
(ticket.assignments ?? [])
|
||||||
|
.map((assignment) => assignment.assigned_to_name)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return names.length ? names.join(', ') : 'N/A';
|
||||||
|
};
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -37,10 +48,19 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
|||||||
size: 120,
|
size: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'assigned_to_name',
|
accessorKey: 'coverage',
|
||||||
header: 'Assigned To',
|
header: 'Coverage',
|
||||||
|
size: 120,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.coverage
|
||||||
|
? `${row.original.coverage.assigned_count}/${row.original.coverage.total_count}`
|
||||||
|
: 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'assignments',
|
||||||
|
header: 'Contractors',
|
||||||
size: 220,
|
size: 220,
|
||||||
cell: ({ row }) => row.original.assigned_to_name || 'N/A',
|
cell: ({ row }) => getAssignedNames(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_by_name',
|
accessorKey: 'created_by_name',
|
||||||
|
|||||||
@@ -20,18 +20,19 @@ function patchTicketLists(
|
|||||||
event: TicketTableStatusEvent,
|
event: TicketTableStatusEvent,
|
||||||
params?: TicketListParams,
|
params?: TicketListParams,
|
||||||
) {
|
) {
|
||||||
if (!current || !event.id) return current;
|
const ticketId = event.id ?? event.ticket_id;
|
||||||
|
if (!current || !ticketId) return current;
|
||||||
|
|
||||||
const nextRow = buildTicketListItem(event);
|
const nextRow = buildTicketListItem(event);
|
||||||
const existingIndex = current.items.findIndex(
|
const existingIndex = current.items.findIndex(
|
||||||
(ticket) => ticket.id === event.id,
|
(ticket) => ticket.id === ticketId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
items: current.items.map((ticket) =>
|
items: current.items.map((ticket) =>
|
||||||
ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket,
|
ticket.id === ticketId ? mergeTicketListItem(ticket, event) : ticket,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,8 +85,9 @@ function getTicketListParams(queryKey: readonly unknown[]) {
|
|||||||
function buildTicketListItem(
|
function buildTicketListItem(
|
||||||
event: TicketTableStatusEvent,
|
event: TicketTableStatusEvent,
|
||||||
): TicketListItem | null {
|
): TicketListItem | null {
|
||||||
|
const ticketId = event.id ?? event.ticket_id;
|
||||||
if (
|
if (
|
||||||
!event.id ||
|
!ticketId ||
|
||||||
!event.status ||
|
!event.status ||
|
||||||
event.detection_count === undefined ||
|
event.detection_count === undefined ||
|
||||||
!event.updated_at
|
!event.updated_at
|
||||||
@@ -94,11 +96,12 @@ function buildTicketListItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: ticketId,
|
||||||
chainage_id: event.chainage_id ?? null,
|
chainage_id: event.chainage_id ?? null,
|
||||||
chainage_name: event.chainage_name ?? null,
|
chainage_name: event.chainage_name ?? null,
|
||||||
status: event.status,
|
status: event.status,
|
||||||
assigned_to_name: event.assigned_to_name ?? null,
|
assignments: event.assignments ?? [],
|
||||||
|
coverage: event.coverage ?? null,
|
||||||
detection_count: event.detection_count,
|
detection_count: event.detection_count,
|
||||||
created_by_name: event.created_by_name ?? null,
|
created_by_name: event.created_by_name ?? null,
|
||||||
created_at: event.created_at ?? event.updated_at,
|
created_at: event.created_at ?? event.updated_at,
|
||||||
@@ -115,7 +118,8 @@ function mergeTicketListItem(
|
|||||||
chainage_id: event.chainage_id ?? current.chainage_id,
|
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||||
chainage_name: event.chainage_name ?? current.chainage_name,
|
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||||
status: event.status ?? current.status,
|
status: event.status ?? current.status,
|
||||||
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
assignments: event.assignments ?? current.assignments,
|
||||||
|
coverage: event.coverage ?? current.coverage,
|
||||||
detection_count: event.detection_count ?? current.detection_count,
|
detection_count: event.detection_count ?? current.detection_count,
|
||||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||||
created_at: event.created_at ?? current.created_at,
|
created_at: event.created_at ?? current.created_at,
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ export function useTicketDetailEvents(
|
|||||||
});
|
});
|
||||||
refetchTicket();
|
refetchTicket();
|
||||||
},
|
},
|
||||||
error: (event: TicketStreamErrorEvent) => {
|
error: (event: TicketStreamErrorEvent | null) => {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
event.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
event?.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
heartbeat: () => undefined,
|
heartbeat: () => undefined,
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import { ticketService } from '@/services/api';
|
import { ticketService } from '@/services/api';
|
||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
|
TicketAssignmentsPayload,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
@@ -71,6 +70,28 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTicketDefectClassesQuery(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||||
|
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||||
|
enabled: Boolean(enabled && ticketId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketAssignmentsQuery(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId ?? ''),
|
||||||
|
queryFn: () => ticketService.getTicketAssignments(ticketId as string),
|
||||||
|
enabled: Boolean(enabled && ticketId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mergeTicketDetailResponse(
|
function mergeTicketDetailResponse(
|
||||||
current: TicketDetail | undefined,
|
current: TicketDetail | undefined,
|
||||||
next: TicketDetail,
|
next: TicketDetail,
|
||||||
@@ -84,21 +105,24 @@ function mergeTicketDetailResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAssignTicketMutation(ticketId: string) {
|
export function useReplaceTicketAssignmentsMutation(ticketId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: AssignTicketPayload) =>
|
mutationFn: (payload: TicketAssignmentsPayload) =>
|
||||||
ticketService.assignTicket(ticketId, payload),
|
ticketService.replaceTicketAssignments(ticketId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Ticket assigned');
|
toast.success('Assignments saved');
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to assign ticket'),
|
onError: () => toast.error('Failed to save assignments'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,48 +143,39 @@ export function useStartTicketMutation(ticketId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubmitRepairMutation(ticketId: string) {
|
export function useSubmitAssignmentRepairUploadMutation(
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: SubmitRepairPayload) =>
|
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||||
ticketService.submitRepair(ticketId, payload),
|
ticketService.submitAssignmentRepairUpload(ticketId, assignmentId, payload),
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Repair submitted');
|
toast.success('Repair submitted');
|
||||||
queryClient.setQueryData<TicketDetail>(
|
queryClient.setQueryData<TicketDetail>(
|
||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to submit repair'),
|
onError: () => toast.error('Failed to submit repair'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubmitRepairUploadMutation(ticketId: string) {
|
export function useReviewAssignmentRepairMutation(
|
||||||
const queryClient = useQueryClient();
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
return useMutation({
|
) {
|
||||||
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
|
||||||
ticketService.submitRepairUpload(ticketId, payload),
|
|
||||||
onSuccess: (ticket) => {
|
|
||||||
toast.success('Repair evidence uploaded');
|
|
||||||
queryClient.setQueryData<TicketDetail>(
|
|
||||||
ticketKeys.detail(ticketId),
|
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Failed to upload repair evidence'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReviewRepairMutation(ticketId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: ReviewRepairPayload) =>
|
mutationFn: (payload: ReviewRepairPayload) =>
|
||||||
ticketService.reviewRepair(ticketId, payload),
|
ticketService.reviewRepair(ticketId, assignmentId, payload),
|
||||||
onSuccess: (ticket, payload) => {
|
onSuccess: (ticket, payload) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||||
@@ -169,6 +184,9 @@ export function useReviewRepairMutation(ticketId: string) {
|
|||||||
ticketKeys.detail(ticketId),
|
ticketKeys.detail(ticketId),
|
||||||
(current) => mergeTicketDetailResponse(current, ticket),
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
);
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to review repair'),
|
onError: () => toast.error('Failed to review repair'),
|
||||||
|
|||||||
@@ -7,4 +7,8 @@ export const ticketKeys = {
|
|||||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||||
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
|
detail: (ticketId: string) => [...ticketKeys.details(), ticketId] as const,
|
||||||
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||||
|
defectClasses: (ticketId: string) =>
|
||||||
|
[...ticketKeys.detail(ticketId), 'defect-classes'] as const,
|
||||||
|
assignments: (ticketId: string) =>
|
||||||
|
[...ticketKeys.detail(ticketId), 'assignments'] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function AssignableWorkerSelect({
|
|||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
portalContainer,
|
||||||
}: AssignableWorkerSelectProps) {
|
}: AssignableWorkerSelectProps) {
|
||||||
const lookup = usePaginatedSelect<AssignableTicketUser>({
|
const lookup = usePaginatedSelect<AssignableTicketUser>({
|
||||||
selectedValue: value,
|
selectedValue: value,
|
||||||
@@ -66,6 +67,7 @@ export function AssignableWorkerSelect({
|
|||||||
placeholder="Select contractor"
|
placeholder="Select contractor"
|
||||||
searchPlaceholder="Search contractors"
|
searchPlaceholder="Search contractors"
|
||||||
emptyMessage="No assignable contractors found."
|
emptyMessage="No assignable contractors found."
|
||||||
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
export interface AssignableWorkerSelectProps {
|
export interface AssignableWorkerSelectProps {
|
||||||
value: string;
|
value: string;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,12 +55,19 @@ export const API_ROUTES = {
|
|||||||
TICKETS: {
|
TICKETS: {
|
||||||
BASE: '/biz/api/v1/tickets',
|
BASE: '/biz/api/v1/tickets',
|
||||||
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
|
||||||
ASSIGN: (id: string) => `/biz/api/v1/tickets/${id}/assign`,
|
DEFECT_CLASSES: (id: string) =>
|
||||||
|
`/biz/api/v1/tickets/${id}/defect-classes`,
|
||||||
|
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
|
||||||
|
ASSIGNMENT_DETAIL: (ticketId: string, assignmentId: number) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}`,
|
||||||
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
START: (id: string) => `/biz/api/v1/tickets/${id}/start`,
|
||||||
SUBMIT_REPAIR: (id: string) => `/biz/api/v1/tickets/${id}/submit-repair`,
|
SUBMIT_ASSIGNMENT_REPAIR_UPLOAD: (
|
||||||
SUBMIT_REPAIR_UPLOAD: (id: string) =>
|
ticketId: string,
|
||||||
`/biz/api/v1/tickets/${id}/submit-repair/upload`,
|
assignmentId: number,
|
||||||
REVIEW: (id: string) => `/biz/api/v1/tickets/${id}/review`,
|
) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/submit-repair/upload`,
|
||||||
|
REVIEW_ASSIGNMENT: (ticketId: string, assignmentId: number) =>
|
||||||
|
`/biz/api/v1/tickets/${ticketId}/assignments/${assignmentId}/review`,
|
||||||
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
CLOSE: (id: string) => `/biz/api/v1/tickets/${id}/close`,
|
||||||
DETAIL_EVENTS_TOKEN: (id: string) =>
|
DETAIL_EVENTS_TOKEN: (id: string) =>
|
||||||
`/biz/api/v1/tickets/${id}/events/token`,
|
`/biz/api/v1/tickets/${id}/events/token`,
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import type {
|
import type {
|
||||||
AssignTicketPayload,
|
|
||||||
AssignableTicketUsersResponse,
|
AssignableTicketUsersResponse,
|
||||||
AssignableTicketUsersParams,
|
AssignableTicketUsersParams,
|
||||||
ReviewRepairPayload,
|
ReviewRepairPayload,
|
||||||
SseTokenResponse,
|
SseTokenResponse,
|
||||||
SubmitRepairPayload,
|
|
||||||
SubmitRepairUploadPayload,
|
SubmitRepairUploadPayload,
|
||||||
|
TicketAssignmentListResponse,
|
||||||
|
TicketAssignmentsPayload,
|
||||||
|
TicketAssignmentSummary,
|
||||||
|
TicketDefectClassesResponse,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketListParams,
|
TicketListParams,
|
||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
|
UpdateTicketAssignmentPayload,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
export const ticketService = {
|
export const ticketService = {
|
||||||
@@ -54,17 +57,78 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
assignTicket: async (
|
getTicketDefectClasses: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: AssignTicketPayload,
|
): Promise<TicketDefectClassesResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketDefectClassesResponse>(
|
||||||
|
API_ROUTES.TICKETS.DEFECT_CLASSES(ticketId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicketAssignments: async (
|
||||||
|
ticketId: string,
|
||||||
|
): Promise<TicketAssignmentListResponse> => {
|
||||||
|
const response = await axiosClient.get<TicketAssignmentListResponse>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicketAssignment: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
): Promise<TicketAssignmentSummary> => {
|
||||||
|
const response = await axiosClient.get<TicketAssignmentSummary>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addTicketAssignments: async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: TicketAssignmentsPayload,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketDetail> => {
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.ASSIGN(ticketId),
|
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
replaceTicketAssignments: async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: TicketAssignmentsPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.put<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNMENTS(ticketId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTicketAssignment: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
payload: UpdateTicketAssignmentPayload,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.patch<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTicketAssignment: async (
|
||||||
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
|
): Promise<TicketDetail> => {
|
||||||
|
const response = await axiosClient.delete<TicketDetail>(
|
||||||
|
API_ROUTES.TICKETS.ASSIGNMENT_DETAIL(ticketId, assignmentId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
startTicket: async (ticketId: string): Promise<TicketDetail> => {
|
startTicket: async (ticketId: string): Promise<TicketDetail> => {
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.START(ticketId),
|
API_ROUTES.TICKETS.START(ticketId),
|
||||||
@@ -72,19 +136,9 @@ export const ticketService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitRepair: async (
|
submitAssignmentRepairUpload: async (
|
||||||
ticketId: string,
|
|
||||||
payload: SubmitRepairPayload,
|
|
||||||
): Promise<TicketDetail> => {
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
|
||||||
API_ROUTES.TICKETS.SUBMIT_REPAIR(ticketId),
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
submitRepairUpload: async (
|
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
payload: SubmitRepairUploadPayload,
|
payload: SubmitRepairUploadPayload,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketDetail> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -95,7 +149,10 @@ export const ticketService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.SUBMIT_REPAIR_UPLOAD(ticketId),
|
API_ROUTES.TICKETS.SUBMIT_ASSIGNMENT_REPAIR_UPLOAD(
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
),
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -108,10 +165,11 @@ export const ticketService = {
|
|||||||
|
|
||||||
reviewRepair: async (
|
reviewRepair: async (
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
|
assignmentId: number,
|
||||||
payload: ReviewRepairPayload,
|
payload: ReviewRepairPayload,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketDetail> => {
|
||||||
const response = await axiosClient.post<TicketDetail>(
|
const response = await axiosClient.post<TicketDetail>(
|
||||||
API_ROUTES.TICKETS.REVIEW(ticketId),
|
API_ROUTES.TICKETS.REVIEW_ASSIGNMENT(ticketId, assignmentId),
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
|||||||
const axiosClient = axios.create({
|
const axiosClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
// 'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { PaginationParams } from '../common';
|
import type { PaginationParams } from '../common';
|
||||||
|
|
||||||
|
export type AssignmentStatus =
|
||||||
|
| 'assigned'
|
||||||
|
| 'under_review'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
export interface AssignableTicketUser {
|
export interface AssignableTicketUser {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,15 +22,70 @@ export interface AssignableTicketUsersResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignTicketPayload {
|
export interface TicketDefectClass {
|
||||||
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDefectClassesResponse {
|
||||||
|
ticket_id: string;
|
||||||
|
video_id: string;
|
||||||
|
defect_classes: TicketDefectClass[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketCoverage {
|
||||||
|
total_defect_classes: string[];
|
||||||
|
assigned_defect_classes: string[];
|
||||||
|
unassigned_defect_classes: string[];
|
||||||
|
is_fully_assigned: boolean;
|
||||||
|
assigned_count: number;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketAssignmentSummary {
|
||||||
|
id: number;
|
||||||
|
assigned_to_user_id: number;
|
||||||
|
assigned_to_email: string | null;
|
||||||
|
assigned_to_name: string | null;
|
||||||
|
defect_class: string;
|
||||||
|
defect_display_name: string;
|
||||||
|
status: AssignmentStatus;
|
||||||
|
note: string | null;
|
||||||
|
assigned_by_user_id: number | null;
|
||||||
|
assigned_by_name: string | null;
|
||||||
|
assigned_at: string | null;
|
||||||
|
repair_notes: string | null;
|
||||||
|
repair_proof_video_url: string | null;
|
||||||
|
repair_proof_image_urls: string[];
|
||||||
|
repair_submitted_at: string | null;
|
||||||
|
reviewed_by_user_id: number | null;
|
||||||
|
reviewed_by_name: string | null;
|
||||||
|
review_comment: string | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketAssignmentListResponse {
|
||||||
|
items: TicketAssignmentSummary[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketAssignmentInput {
|
||||||
assigned_to_user_id: number;
|
assigned_to_user_id: number;
|
||||||
assigned_to_email?: string;
|
assigned_to_email?: string;
|
||||||
|
defect_class: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRepairPayload {
|
export interface TicketAssignmentsPayload {
|
||||||
notes: string;
|
assignments: TicketAssignmentInput[];
|
||||||
proof_path: string;
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTicketAssignmentPayload {
|
||||||
|
assigned_to_user_id?: number;
|
||||||
|
assigned_to_email?: string;
|
||||||
|
defect_class?: string;
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitRepairUploadPayload {
|
export interface SubmitRepairUploadPayload {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||||
import type { TicketStatus } from './status';
|
import type { TicketStatus } from './status';
|
||||||
|
|
||||||
export interface TicketActor {
|
export interface TicketActor {
|
||||||
@@ -32,26 +33,10 @@ export interface TicketVideo {
|
|||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketWorker extends TicketActor {
|
|
||||||
assigned_at: string | null;
|
|
||||||
assigned_by: TicketActor | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketRepair {
|
|
||||||
notes: string | null;
|
|
||||||
proof_video_url: string | null;
|
|
||||||
proof_image_urls: string[];
|
|
||||||
submitted_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketReviewer extends TicketActor {
|
|
||||||
review_comment: string | null;
|
|
||||||
reviewed_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketAiResult {
|
export interface TicketAiResult {
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
|
available_defect_classes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketTenant {
|
export interface TicketTenant {
|
||||||
@@ -68,13 +53,13 @@ export interface TicketDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
video_id: string;
|
video_id: string;
|
||||||
chainage_id: string | null;
|
chainage_id: string | null;
|
||||||
|
chainage_name?: string | null;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
status_metadata: TicketStatusMetadata | null;
|
status_metadata: TicketStatusMetadata | null;
|
||||||
video: TicketVideo | null;
|
video: TicketVideo | null;
|
||||||
uploader: TicketActor | null;
|
uploader: TicketActor | null;
|
||||||
worker: TicketWorker | null;
|
assignments: TicketAssignmentSummary[];
|
||||||
repair: TicketRepair | null;
|
coverage: TicketCoverage | null;
|
||||||
reviewer: TicketReviewer | null;
|
|
||||||
ai_result: TicketAiResult | null;
|
ai_result: TicketAiResult | null;
|
||||||
tenant: TicketTenant | null;
|
tenant: TicketTenant | null;
|
||||||
timestamps: TicketTimestamps;
|
timestamps: TicketTimestamps;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TicketDetail } from './detail';
|
import type { TicketDetail } from './detail';
|
||||||
|
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||||
import type { TicketStatus } from './status';
|
import type { TicketStatus } from './status';
|
||||||
|
|
||||||
export interface SseTokenResponse {
|
export interface SseTokenResponse {
|
||||||
@@ -8,12 +9,15 @@ export interface SseTokenResponse {
|
|||||||
|
|
||||||
export type TicketTableStatusEvent = {
|
export type TicketTableStatusEvent = {
|
||||||
type?: 'ticket_status';
|
type?: 'ticket_status';
|
||||||
kind?: 'created' | 'transitioned';
|
kind?: 'created' | 'transitioned' | 'snapshot';
|
||||||
id: string;
|
id?: string;
|
||||||
|
ticket_id?: string;
|
||||||
chainage_id?: string | null;
|
chainage_id?: string | null;
|
||||||
chainage_name?: string | null;
|
chainage_name?: string | null;
|
||||||
status?: TicketStatus;
|
status?: TicketStatus;
|
||||||
assigned_to_name?: string | null;
|
assignments?: TicketAssignmentSummary[];
|
||||||
|
coverage?: TicketCoverage | null;
|
||||||
|
assignment_summary?: Partial<Record<string, number>>;
|
||||||
detection_count?: number;
|
detection_count?: number;
|
||||||
created_by_name?: string | null;
|
created_by_name?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PaginationParams } from '../common';
|
import type { PaginationParams } from '../common';
|
||||||
|
import type { TicketAssignmentSummary, TicketCoverage } from './actions';
|
||||||
import type { TicketStatus } from './status';
|
import type { TicketStatus } from './status';
|
||||||
|
|
||||||
export interface TicketListParams extends PaginationParams {
|
export interface TicketListParams extends PaginationParams {
|
||||||
@@ -11,7 +12,8 @@ export interface TicketListItem {
|
|||||||
chainage_id: string | null;
|
chainage_id: string | null;
|
||||||
chainage_name: string | null;
|
chainage_name: string | null;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
assigned_to_name: string | null;
|
assignments: TicketAssignmentSummary[];
|
||||||
|
coverage: TicketCoverage | null;
|
||||||
detection_count: number;
|
detection_count: number;
|
||||||
created_by_name: string | null;
|
created_by_name: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user