From c9b7d7184382e5d8e4d6ae19097a3dee7e2b1cbe Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 2 Jun 2026 14:10:05 +0000 Subject: [PATCH] Show + edit sample classifications on awaiting_review (#298, #299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #298: read the classifier's resolved enums for the multi-entry sample's entries (joined from landlord_*_overrides by normalized description) and show each beside the raw text, or "not classified" when absent. #299: make each classification editable — a dropdown of the category's valid enum values whose selection upserts the override by (portfolio, description) with source='user', so the classifier never re-clobbers it. UI notes the portfolio-wide blast radius. Verification ack is folded into the existing order-confirm (no separate flag/migration); editing is optional review. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[uploadId]/classifications/route.ts | 59 +++++++ .../[uploadId]/OnboardingProgress.tsx | 78 ++++++++- src/lib/bulkUpload/client.ts | 44 +++++ src/lib/bulkUpload/server.ts | 153 +++++++++++++++++- 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/classifications/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/classifications/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/classifications/route.ts new file mode 100644 index 00000000..e897319a --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/classifications/route.ts @@ -0,0 +1,59 @@ +import { getSampleClassifications, setClassificationOverride } from "@/lib/bulkUpload/server"; +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; + +// Read-only: the classifier's resolved enums for the multi-entry sample's +// entries, keyed by field -> description -> value (ADR-0004, issue #298). +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + const classifications = await getSampleClassifications(uploadId); + return NextResponse.json({ classifications }, { status: 200 }); +} + +const PatchSchema = z.object({ + field: z.string(), + description: z.string(), + value: z.string(), +}); + +// Correct one classification, written as a user override (source='user'). The +// edit is per-(portfolio, description), so it applies portfolio-wide (issue #299). +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { portfolioId } = await params; + + let body; + try { + body = PatchSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + try { + const result = await setClassificationOverride( + portfolioId, + body.field, + body.description, + body.value, + ); + if (result.kind === "invalid") + return NextResponse.json({ error: result.reason }, { status: 422 }); + return NextResponse.json({ ok: true }, { status: 200 }); + } catch (error) { + console.error("Failed to save classification override:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index b5ae7f43..0e7284a1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -7,8 +7,11 @@ import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { useBulkUploadProgress, useConfirmMultiEntryOrdering, + useEditClassification, useFinalize, useRequestCombine, + useSampleClassifications, + type SampleClassifications, } from "@/lib/bulkUpload/client"; import { partLabel, @@ -17,6 +20,20 @@ import { type MultiEntrySample, } from "@/lib/bulkUpload/multiEntry"; import type { MultiEntryOrdering } from "@/app/db/schema/bulk_address_uploads"; +import { + PropertyTypeValues, + BuiltFormTypeValues, + WallTypeValues, + RoofTypeValues, +} from "@/app/db/schema/landlord_overrides"; + +// Valid enum options per classifier category, for the editable dropdowns (#299). +const CATEGORY_VALUES: Record = { + property_type: PropertyTypeValues, + built_form_type: BuiltFormTypeValues, + wall_type: WallTypeValues, + roof_type: RoofTypeValues, +}; interface Props { portfolioSlug: string; @@ -39,6 +56,14 @@ export default function OnboardingProgress({ const combine = useRequestCombine(portfolioId, uploadId); const finalize = useFinalize(portfolioId, uploadId); + // Read-only classifications for the multi-entry sample (issue #298). Fetched + // only once a sample exists at awaiting_review. Hook stays above the early + // returns so its order is stable. + const sampleReady = + progress.data?.upload.status === "awaiting_review" && + !!progress.data.upload.multiEntrySummary?.sample; + const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady); + if (progress.isError) return null; if (!progress.data) { return ( @@ -141,6 +166,7 @@ export default function OnboardingProgress({ @@ -200,15 +226,18 @@ export default function OnboardingProgress({ function MultiEntryOrderingPanel({ sample, ordering, + classifications, portfolioId, uploadId, }: { sample: MultiEntrySample; ordering: MultiEntryOrdering | null; + classifications: SampleClassifications; portfolioId: string; uploadId: string; }) { const confirm = useConfirmMultiEntryOrdering(portfolioId, uploadId); + const editClassification = useEditClassification(portfolioId, uploadId); const count = sample.count; // assignment[filePosition] = building-part slot. Seed from a stored ordering @@ -267,11 +296,41 @@ function MultiEntryOrderingPanel({ {Array.from({ length: count }).map((_, position) => ( {position + 1} - {sample.columns.map((column) => ( - - {column.entries[position]?.raw ?? "—"} - - ))} + {sample.columns.map((column) => { + const entry = column.entries[position]; + const classified = entry + ? classifications[column.field]?.[entry.description] ?? "" + : ""; + const options = CATEGORY_VALUES[column.field] ?? []; + return ( + +
{entry?.raw ?? "—"}
+ {entry && ( + + )} + + ); + })}