From 4288f89d8ba3c5996accbf967800259acf4e6539 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 12 Mar 2026 09:32:46 +0700 Subject: [PATCH] 260312:0932 20260312:0930 n8n workflow, backend and frontend MOD. --- .../dto/import-correspondence.dto.ts | 2 +- .../dto/migration-queue-query.dto.ts | 27 + .../entities/migration-error.entity.ts | 44 + .../entities/migration-review-queue.entity.ts | 61 + .../modules/migration/migration.controller.ts | 80 +- .../src/modules/migration/migration.module.ts | 5 + .../modules/migration/migration.service.ts | 160 ++- .../admin/migration/errors/page.tsx | 102 ++ .../app/(dashboard)/admin/migration/page.tsx | 140 +++ .../admin/migration/review/[id]/page.tsx | 362 ++++++ frontend/components/admin/sidebar.tsx | 10 +- frontend/lib/services/migration.service.ts | 54 + frontend/types/migration.ts | 47 + specs/03-Data-and-Storage/Legacy-Import.json | 1003 ----------------- specs/03-Data-and-Storage/n8n.workflow.json | 4 +- 15 files changed, 1091 insertions(+), 1010 deletions(-) create mode 100644 backend/src/modules/migration/dto/migration-queue-query.dto.ts create mode 100644 backend/src/modules/migration/entities/migration-error.entity.ts create mode 100644 backend/src/modules/migration/entities/migration-review-queue.entity.ts create mode 100644 frontend/app/(dashboard)/admin/migration/errors/page.tsx create mode 100644 frontend/app/(dashboard)/admin/migration/page.tsx create mode 100644 frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx create mode 100644 frontend/lib/services/migration.service.ts create mode 100644 frontend/types/migration.ts delete mode 100644 specs/03-Data-and-Storage/Legacy-Import.json diff --git a/backend/src/modules/migration/dto/import-correspondence.dto.ts b/backend/src/modules/migration/dto/import-correspondence.dto.ts index eae3de6..18b37ca 100644 --- a/backend/src/modules/migration/dto/import-correspondence.dto.ts +++ b/backend/src/modules/migration/dto/import-correspondence.dto.ts @@ -13,7 +13,7 @@ export class ImportCorrespondenceDto { @IsString() @IsNotEmpty() - title!: string; + subject!: string; @IsString() @IsNotEmpty() diff --git a/backend/src/modules/migration/dto/migration-queue-query.dto.ts b/backend/src/modules/migration/dto/migration-queue-query.dto.ts new file mode 100644 index 0000000..07fac17 --- /dev/null +++ b/backend/src/modules/migration/dto/migration-queue-query.dto.ts @@ -0,0 +1,27 @@ +import { IsOptional, IsEnum, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { MigrationReviewStatus } from '../entities/migration-review-queue.entity'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 10 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 10; +} + +export class MigrationQueueQueryDto extends PaginationDto { + @ApiPropertyOptional({ enum: MigrationReviewStatus }) + @IsOptional() + @IsEnum(MigrationReviewStatus) + status?: MigrationReviewStatus; +} diff --git a/backend/src/modules/migration/entities/migration-error.entity.ts b/backend/src/modules/migration/entities/migration-error.entity.ts new file mode 100644 index 0000000..fd5ca0d --- /dev/null +++ b/backend/src/modules/migration/entities/migration-error.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; + +export enum MigrationErrorType { + FILE_NOT_FOUND = 'FILE_NOT_FOUND', + AI_PARSE_ERROR = 'AI_PARSE_ERROR', + API_ERROR = 'API_ERROR', + DB_ERROR = 'DB_ERROR', + SECURITY = 'SECURITY', + UNKNOWN = 'UNKNOWN', +} + +@Entity('migration_errors') +export class MigrationError { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'batch_id', length: 50, nullable: true }) + batchId?: string; + + @Column({ name: 'document_number', length: 100, nullable: true }) + documentNumber?: string; + + @Column({ + name: 'error_type', + type: 'enum', + enum: MigrationErrorType, + nullable: true, + }) + errorType?: MigrationErrorType; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @Column({ name: 'raw_ai_response', type: 'text', nullable: true }) + rawAiResponse?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/migration/entities/migration-review-queue.entity.ts b/backend/src/modules/migration/entities/migration-review-queue.entity.ts new file mode 100644 index 0000000..1c931dd --- /dev/null +++ b/backend/src/modules/migration/entities/migration-review-queue.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; + +export enum MigrationReviewStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} + +@Entity('migration_review_queue') +export class MigrationReviewQueue { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'document_number', length: 100, unique: true }) + documentNumber!: string; + + @Column({ type: 'text', nullable: true }) + title?: string; + + @Column({ name: 'original_title', type: 'text', nullable: true }) + originalTitle?: string; + + @Column({ name: 'ai_suggested_category', length: 50, nullable: true }) + aiSuggestedCategory?: string; + + @Column({ + name: 'ai_confidence', + type: 'decimal', + precision: 4, + scale: 3, + nullable: true, + }) + aiConfidence?: number; + + @Column({ name: 'ai_issues', type: 'json', nullable: true }) + aiIssues?: any; + + @Column({ name: 'review_reason', length: 255, nullable: true }) + reviewReason?: string; + + @Column({ + type: 'enum', + enum: MigrationReviewStatus, + default: MigrationReviewStatus.PENDING, + }) + status!: MigrationReviewStatus; + + @Column({ name: 'reviewed_by', length: 100, nullable: true }) + reviewedBy?: string; + + @Column({ name: 'reviewed_at', type: 'timestamp', nullable: true }) + reviewedAt?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/migration/migration.controller.ts b/backend/src/modules/migration/migration.controller.ts index 36b7536..361fb33 100644 --- a/backend/src/modules/migration/migration.controller.ts +++ b/backend/src/modules/migration/migration.controller.ts @@ -3,7 +3,10 @@ import { MigrationService } from './migration.service'; import { ImportCorrespondenceDto } from './dto/import-correspondence.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto'; +import { Get, Param, Query, Res, ParseIntPipe, Body, Headers, Post, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; @ApiTags('Migration') @ApiBearerAuth() @@ -27,4 +30,79 @@ export class MigrationController { const userId = user?.id || user?.userId || 5; return this.migrationService.importCorrespondence(dto, idempotencyKey, userId); } + + @Get('queue') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get migration review queue' }) + async getReviewQueue(@Query() query: MigrationQueueQueryDto) { + return this.migrationService.getReviewQueue(query); + } + + @Get('queue/:id') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get a specific queue item by ID' }) + @ApiParam({ name: 'id', type: Number }) + async getQueueItemById(@Param('id', ParseIntPipe) id: number) { + return this.migrationService.getQueueItemById(id); + } + + @Get('errors') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get migration errors' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getErrors( + @Query('page') page?: number, + @Query('limit') limit?: number + ) { + return this.migrationService.getErrors(page, limit); + } + + @Post('queue/:id/approve') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Approve and import a queued migration item' }) + @ApiParam({ name: 'id', type: Number }) + @ApiHeader({ + name: 'Idempotency-Key', + description: 'Unique key per document and batch to prevent duplicate inserts', + required: true, + }) + async approveQueueItem( + @Param('id', ParseIntPipe) id: number, + @Body() dto: ImportCorrespondenceDto, + @Headers('idempotency-key') idempotencyKey: string, + @CurrentUser() user: any + ) { + const userId = user?.id || user?.userId || 5; + return this.migrationService.approveQueueItem(id, dto, idempotencyKey, userId); + } + + @Post('queue/:id/reject') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Reject a queued migration item' }) + @ApiParam({ name: 'id', type: Number }) + async rejectQueueItem( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: any + ) { + const userId = user?.id || user?.userId || 5; + return this.migrationService.rejectQueueItem(id, userId); + } + + @Get('staging-file') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Stream a file from staging' }) + @ApiQuery({ name: 'path', required: true, type: String }) + async getStagingFile( + @Query('path') filePath: string, + @Res() res: Response + ) { + const stream = this.migrationService.getStagingFileStream(filePath); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'inline; filename="document.pdf"', + }); + stream.pipe(res); + } } + diff --git a/backend/src/modules/migration/migration.module.ts b/backend/src/modules/migration/migration.module.ts index 0c8de8d..a60e45c 100644 --- a/backend/src/modules/migration/migration.module.ts +++ b/backend/src/modules/migration/migration.module.ts @@ -10,10 +10,15 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence- import { Project } from '../project/entities/project.entity'; import { FileStorageModule } from '../../common/file-storage/file-storage.module'; +import { MigrationReviewQueue } from './entities/migration-review-queue.entity'; +import { MigrationError } from './entities/migration-error.entity'; + @Module({ imports: [ TypeOrmModule.forFeature([ ImportTransaction, + MigrationReviewQueue, + MigrationError, Correspondence, CorrespondenceRevision, CorrespondenceType, diff --git a/backend/src/modules/migration/migration.service.ts b/backend/src/modules/migration/migration.service.ts index 2c5489d..b66e4f7 100644 --- a/backend/src/modules/migration/migration.service.ts +++ b/backend/src/modules/migration/migration.service.ts @@ -15,7 +15,14 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { Project } from '../project/entities/project.entity'; import { FileStorageService } from '../../common/file-storage/file-storage.service'; - +import { + MigrationReviewQueue, + MigrationReviewStatus, +} from './entities/migration-review-queue.entity'; +import { MigrationError } from './entities/migration-error.entity'; +import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto'; +import { createReadStream, existsSync } from 'fs'; +import * as path from 'path'; @Injectable() export class MigrationService { private readonly logger = new Logger(MigrationService.name); @@ -30,6 +37,10 @@ export class MigrationService { private readonly correspondenceStatusRepo: Repository, @InjectRepository(Project) private readonly projectRepo: Repository, + @InjectRepository(MigrationReviewQueue) + private readonly reviewQueueRepo: Repository, + @InjectRepository(MigrationError) + private readonly errorRepo: Repository, private readonly fileStorageService: FileStorageService ) {} @@ -202,7 +213,7 @@ export class MigrationService { revisionLabel: revNum === 0 ? '0' : revNum.toString(), isCurrent: true, statusId: status.id, - subject: dto.title, + subject: dto.subject, description: 'Migrated from legacy system via Auto Ingest', body: dto.body || undefined, documentDate: parseDateStr(dto.document_date || dto.issued_date), @@ -248,6 +259,50 @@ export class MigrationService { await queryRunner.manager.save('RfaRevision', rfaRev); } + // 5.5 Handle Tags + if ( + dto.details && + Array.isArray(dto.details.tags) && + dto.details.tags.length > 0 + ) { + for (const tagItem of dto.details.tags) { + let tagName: string | undefined; + + if (typeof tagItem === 'string') { + tagName = tagItem; + } else if (tagItem && typeof tagItem === 'object') { + const tObj = tagItem as { tag_name?: unknown }; + if (typeof tObj.tag_name === 'string') { + tagName = tObj.tag_name; + } + } + + if (!tagName) continue; + + // Find or create Tag + const tagRes = (await queryRunner.manager.query( + 'SELECT id FROM tags WHERE project_id = ? AND tag_name = ? LIMIT 1', + [project.id, tagName] + )) as Array<{ id: number }>; + + let tagId: number; + if (tagRes && tagRes.length > 0) { + tagId = tagRes[0].id; + } else { + const insertRes = (await queryRunner.manager.query( + "INSERT INTO tags (project_id, tag_name, color_code, created_by) VALUES (?, ?, 'default', ?)", + [project.id, tagName, userId] + )) as { insertId: number }; + tagId = insertRes.insertId; + } + + // Link to correspondence + await queryRunner.manager.query( + 'INSERT IGNORE INTO correspondence_tags (correspondence_id, tag_id) VALUES (?, ?)', + [correspondence.id, tagId] + ); + } + } // 6. Track Transaction const transaction = queryRunner.manager.create(ImportTransaction, { idempotencyKey, @@ -295,4 +350,105 @@ export class MigrationService { await queryRunner.release(); } } + async getReviewQueue(query: MigrationQueueQueryDto) { + const { page = 1, limit = 10, status } = query; + const skip = (page - 1) * limit; + + const queryBuilder = this.reviewQueueRepo.createQueryBuilder('queue'); + if (status) { + queryBuilder.where('queue.status = :status', { status }); + } + + queryBuilder.orderBy('queue.createdAt', 'DESC'); + queryBuilder.skip(skip).take(limit); + + const [items, total] = await queryBuilder.getManyAndCount(); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getQueueItemById(id: number) { + const item = await this.reviewQueueRepo.findOne({ where: { id } }); + if (!item) { + throw new BadRequestException(`Queue item with ID ${id} not found`); + } + return item; + } + + async getErrors(page: number = 1, limit: number = 10) { + const skip = (page - 1) * limit; + + const [items, total] = await this.errorRepo.findAndCount({ + order: { createdAt: 'DESC' }, + skip, + take: limit, + }); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async approveQueueItem(id: number, dto: ImportCorrespondenceDto, idempotencyKey: string, userId: number) { + const queueItem = await this.reviewQueueRepo.findOne({ where: { id } }); + if (!queueItem) { + throw new BadRequestException('Queue item not found'); + } + + if (queueItem.status !== MigrationReviewStatus.PENDING) { + throw new BadRequestException(`Queue item is already ${queueItem.status}`); + } + + // Attempt the import + const result = await this.importCorrespondence(dto, idempotencyKey, userId); + + // If successful, update the queue item status + queueItem.status = MigrationReviewStatus.APPROVED; + queueItem.reviewedBy = userId.toString(); + queueItem.reviewedAt = new Date(); + await this.reviewQueueRepo.save(queueItem); + + return result; + } + + async rejectQueueItem(id: number, userId: number) { + const queueItem = await this.reviewQueueRepo.findOne({ where: { id } }); + if (!queueItem) { + throw new BadRequestException('Queue item not found'); + } + + queueItem.status = MigrationReviewStatus.REJECTED; + queueItem.reviewedBy = userId.toString(); + queueItem.reviewedAt = new Date(); + await this.reviewQueueRepo.save(queueItem); + + return { + message: 'Document rejected successfully', + id: queueItem.id, + }; + } + + getStagingFileStream(filePath: string) { + if (!filePath) { + throw new BadRequestException('File path is required'); + } + + const resolvedPath = path.resolve(filePath); + if (!existsSync(resolvedPath)) { + throw new BadRequestException('File not found at specified path'); + } + + return createReadStream(resolvedPath); + } } + diff --git a/frontend/app/(dashboard)/admin/migration/errors/page.tsx b/frontend/app/(dashboard)/admin/migration/errors/page.tsx new file mode 100644 index 0000000..7da95cb --- /dev/null +++ b/frontend/app/(dashboard)/admin/migration/errors/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { migrationService } from "@/lib/services/migration.service"; +import { MigrationErrorItem } from "@/types/migration"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { format } from "date-fns"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function MigrationErrorsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + setLoading(true); + const res = await migrationService.getErrors({ limit: 100 }); + setItems(res.items); + } catch (error) { + console.error("Failed to fetch errors", error); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Migration Errors

+

+ Systemic errors encountered during the background migration process. +

+
+ + + +
+ + + + Error Audit Log + + + {loading ? ( +
Loading errors...
+ ) : items.length === 0 ? ( +
No errors found.
+ ) : ( +
+ + + + Batch ID + Document No. + Error Type + Error Message + Occurred At + + + + {items.map((item) => ( + + {item.batchId || "-"} + {item.documentNumber || "-"} + + {item.errorType || "UNKNOWN"} + + + + {item.errorMessage || "-"} + + + {format(new Date(item.createdAt), "dd MMM yyyy, HH:mm")} + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/admin/migration/page.tsx b/frontend/app/(dashboard)/admin/migration/page.tsx new file mode 100644 index 0000000..72ca654 --- /dev/null +++ b/frontend/app/(dashboard)/admin/migration/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { migrationService } from "@/lib/services/migration.service"; +import { MigrationReviewQueueItem, MigrationReviewStatus } from "@/types/migration"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { format } from "date-fns"; +import { EyeIcon, FileXIcon } from "lucide-react"; +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function MigrationReviewQueuePage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState("PENDING"); + + useEffect(() => { + fetchData(); + }, [statusFilter]); + + const fetchData = async () => { + try { + setLoading(true); + const res = await migrationService.getReviewQueue({ + status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus), + limit: 50, + }); + setItems(res.items); + } catch (error) { + console.error("Failed to fetch queue", error); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Migration Review Queue

+

+ Review and correct documents that AI flagged as low confidence. +

+
+
+ + + + +
+
+ + + + Queue Items - {statusFilter} + + + {loading ? ( +
Loading queue...
+ ) : items.length === 0 ? ( +
No items in the queue.
+ ) : ( +
+ + + + Document No. + Suggested Category + Confidence + Status + Created At + Action + + + + {items.map((item) => ( + + {item.documentNumber} + {item.aiSuggestedCategory || "Unknown"} + + 0.8 + ? "default" + : item.aiConfidence > 0.5 + ? "secondary" + : "destructive" + } + > + {item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + "%" : "N/A"} + + + + + {item.status} + + + {format(new Date(item.createdAt), "dd MMM yyyy, HH:mm")} + + + + + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx b/frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx new file mode 100644 index 0000000..c4ce64b --- /dev/null +++ b/frontend/app/(dashboard)/admin/migration/review/[id]/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { migrationService } from "@/lib/services/migration.service"; +import { MigrationReviewQueueItem } from "@/types/migration"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ArrowLeftIcon, CheckCircleIcon, XCircleIcon } from "lucide-react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; + +const reviewFormSchema = z.object({ + document_number: z.string().min(1, "Document number is required"), + subject: z.string().min(1, "Subject is required"), + category: z.string().min(1, "Category is required"), + document_date: z.string().optional(), + issued_date: z.string().optional(), + received_date: z.string().optional(), + sender_id: z.string().optional(), + discipline_id: z.string().optional(), +}); + +type ReviewFormValues = z.infer; + +export default function MigrationReviewPage() { + const params = useParams(); + const router = useRouter(); + const id = Number(params.id); + + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(reviewFormSchema), + defaultValues: { + document_number: "", + subject: "", + category: "", + document_date: "", + issued_date: "", + received_date: "", + sender_id: "", + discipline_id: "", + }, + }); + + useEffect(() => { + if (!id) return; + fetchItem(id); + }, [id]); + + const fetchItem = async (itemId: number) => { + try { + setLoading(true); + const res = await migrationService.getQueueItem(itemId); + setItem(res); + + if (res) { + // Pre-fill form from database item and aiIssues payload + const issues = res.aiIssues || {}; + form.reset({ + document_number: res.documentNumber || "", + subject: res.title || res.originalTitle || "", + category: res.aiSuggestedCategory || "", + document_date: issues.document_date || "", + issued_date: issues.issued_date || "", + received_date: issues.received_date || "", + sender_id: issues.sender_id ? String(issues.sender_id) : "", + discipline_id: issues.discipline_id ? String(issues.discipline_id) : "", + }); + } + } catch (error) { + console.error("Failed to load queue item", error); + toast.error("Failed to load queue item"); + } finally { + setLoading(false); + } + }; + + const onSubmit = async (values: ReviewFormValues) => { + if (!item) return; + + try { + setSubmitting(true); + const issues = item.aiIssues || {}; + + const payload = { + document_number: values.document_number, + subject: values.subject, + category: values.category, + source_file_path: issues.source_file_path || "", + migrated_by: "SYSTEM_IMPORT", + batch_id: "MANUAL_REVIEW_BATCH", + project_id: 1, // Assumption or pulled from store + document_date: values.document_date, + issued_date: values.issued_date, + received_date: values.received_date, + sender_id: values.sender_id ? Number(values.sender_id) : undefined, + discipline_id: values.discipline_id ? Number(values.discipline_id) : undefined, + details: { + tags: issues.tags || [], + ai_confidence: item.aiConfidence, + } + }; + + // Mock idempotency key based on timestamp to ensure uniqueness per approval retry + const idempotencyKey = `review-${item.id}-${Date.now()}`; + await migrationService.approveQueueItem(item.id, payload, idempotencyKey); + + toast.success("Document approved and imported successfully"); + router.push("/admin/migration"); + } catch (error: any) { + console.error("Failed to approve item", error); + toast.error(error?.response?.data?.message || "Failed to approve and import"); + } finally { + setSubmitting(false); + } + }; + + const onReject = async () => { + if (!item || !confirm("Are you sure you want to REJECT this document? It will not be imported.")) return; + + try { + setSubmitting(true); + await migrationService.rejectQueueItem(item.id); + toast.success("Document rejected"); + router.push("/admin/migration"); + } catch (error: any) { + console.error("Failed to reject item", error); + toast.error("Failed to reject document"); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return
Loading document data...
; + } + + if (!item) { + return
Document not found
; + } + + const pdfUrl = item.aiIssues?.source_file_path + ? migrationService.getStagingFileUrl(item.aiIssues.source_file_path) + : null; + + return ( +
+
+
+ + + +
+

Review Document: {item.documentNumber}

+

+ Status: {item.status} + {' | '} Confidence: {item.aiConfidence ? (item.aiConfidence * 100).toFixed(1) + "%" : "N/A"} +

+
+
+
+ +
+ {/* Left Side: PDF Viewer */} + + + {pdfUrl ? ( +