Updating UI for document upload to improve confusing categorisation
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-10 20:11:33 +00:00
parent 9654c2cfd7
commit f6b9f9f65c
3 changed files with 307 additions and 25 deletions

View file

@ -24,6 +24,13 @@ import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import { parseMeasures } from "@/app/lib/parseMeasures";
import {
applyBulkDocType,
selectAllUnclassified,
getClassifiedCount,
getUnclassifiedIds,
uploadedCountForType,
} from "./classifyPhase";
// ── Types ─────────────────────────────────────────────────────────────────
@ -272,7 +279,6 @@ function DocTypeButtonGrid({
uploadedDocs: string[];
}) {
const [showOther, setShowOther] = useState(false);
const uploadedSet = new Set(uploadedDocs);
const requiredSet = new Set(requiredDocs);
const isOtherSelected = value !== "" && !requiredSet.has(value);
@ -283,7 +289,7 @@ function DocTypeButtonGrid({
{requiredDocs.map((docType) => {
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
const label = option?.label ?? docType;
const alreadyUploaded = uploadedSet.has(docType);
const uploadedCount = uploadedCountForType(uploadedDocs, docType);
const isSelected = value === docType;
return (
@ -291,21 +297,19 @@ function DocTypeButtonGrid({
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
title={uploadedCount > 0 ? `${label} ${uploadedCount} already uploaded` : label}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
{uploadedCount > 0 && !isSelected && (
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-gray-100 text-gray-500 text-[10px] font-semibold leading-none">
{uploadedCount}
</span>
)}
</button>
);
})}
@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
const [saveError, setSaveError] = useState<string | null>(null);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// Bulk classify state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDocType, setBulkDocType] = useState("");
// ── Fetch existing unclassified files on mount ───────────────────────
@ -581,14 +588,35 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
}
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
const classifiedCount = getClassifiedCount(classifiableEntries);
const unclassifiedCount = getUnclassifiedIds(classifiableEntries).length;
function handleBulkApply() {
if (!bulkDocType) return;
setQueue((prev) => applyBulkDocType(prev, selectedIds, bulkDocType));
setSelectedIds(new Set());
setBulkDocType("");
}
function toggleSelectId(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function handleSelectAllUnclassified() {
setSelectedIds(selectAllUnclassified(classifiableEntries));
}
async function handleSaveClassifications() {
setSaveError(null);
setIsSaving(true);
try {
const toSave = classifiableEntries.filter((f) => f.docType !== "");
await saveClassifications(
classifiableEntries.map((f) => ({
toSave.map((f) => ({
id: f.uploadedId!,
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
</div>
)}
{/* Select-all row */}
{classifiableEntries.length > 0 && (
<div className="flex items-center gap-2 px-1">
<input
type="checkbox"
id="select-all-unclassified"
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer"
checked={selectedIds.size > 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size}
onChange={() => {
const allUnclassified = selectAllUnclassified(classifiableEntries);
setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified);
}}
/>
<label htmlFor="select-all-unclassified" className="text-xs text-gray-500 cursor-pointer select-none">
Select all unclassified ({unclassifiedCount})
</label>
</div>
)}
{/* File list with classification */}
<div className="space-y-3">
{classifiableEntries.map((entry) => {
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
const isChecked = selectedIds.has(entry.id);
return (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
<div key={entry.id} className={`rounded-lg border bg-gray-50/50 p-3 space-y-2.5 transition-colors ${isChecked ? "border-brandblue/30 bg-brandlightblue/5" : "border-gray-100"}`}>
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer shrink-0"
checked={isChecked}
onChange={() => toggleSelectId(entry.id)}
/>
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
})()}
</div>
{/* ── Bulk classify toolbar — shown when files are selected ── */}
{phase === "classify" && selectedIds.size > 0 && (
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-brandlightblue/10 border-t border-brandblue/20">
<span className="text-xs font-semibold text-brandblue whitespace-nowrap">
{selectedIds.size} selected
</span>
<span className="text-xs text-gray-400"></span>
<span className="text-xs text-gray-600 whitespace-nowrap">Classify as:</span>
<Select value={bulkDocType} onValueChange={setBulkDocType}>
<SelectTrigger className="h-7 text-xs flex-1 max-w-56">
<SelectValue placeholder="Choose type…" />
</SelectTrigger>
<SelectContent>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">{group}</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
<Button
size="sm"
className="h-7 text-xs bg-brandblue text-white shrink-0"
disabled={!bulkDocType}
onClick={handleBulkApply}
>
Apply
</Button>
<button
type="button"
className="text-xs text-gray-400 hover:text-gray-600 shrink-0 ml-1"
onClick={() => setSelectedIds(new Set())}
>
Clear
</button>
</div>
)}
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
{phase === "loading" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
Skip for now
</Button>
<Button
onClick={handleSaveClassifications}
disabled={!allClassified || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
"Save Classifications →"
<div className="flex flex-col items-end gap-0.5">
<Button
onClick={handleSaveClassifications}
disabled={classifiedCount === 0 || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
`Save ${classifiedCount} classified →`
)}
</Button>
{unclassifiedCount > 0 && (
<p className="text-[10px] text-gray-400">
{unclassifiedCount} unclassified will stay in your queue
</p>
)}
</Button>
</div>
</>
)}
</DialogFooter>

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import {
applyBulkDocType,
selectAllUnclassified,
getClassifiedCount,
getUnclassifiedIds,
uploadedCountForType,
} from "./classifyPhase";
import type { ClassifyEntry } from "./classifyPhase";
function makeEntry(overrides: Partial<ClassifyEntry> = {}): ClassifyEntry {
return {
id: "1",
docType: "",
status: "done",
...overrides,
};
}
// ── applyBulkDocType ─────────────────────────────────────────────────────────
describe("applyBulkDocType", () => {
it("applies docType to selected entries", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
});
it("leaves unselected entries unchanged", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b", docType: "post_photo" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "b")?.docType).toBe("post_photo");
});
it("overwrites an existing docType on selected entry", () => {
const entries = [makeEntry({ id: "a", docType: "mid_photo" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
});
it("skips non-done entries even if selected", () => {
const entries = [makeEntry({ id: "a", status: "error" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("");
});
it("returns same length array", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const result = applyBulkDocType(entries, new Set(["a", "b"]), "pre_photo");
expect(result).toHaveLength(2);
});
});
// ── selectAllUnclassified ────────────────────────────────────────────────────
describe("selectAllUnclassified", () => {
it("returns IDs of done entries with no docType", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["a", "b"]));
});
it("excludes already-classified entries", () => {
const entries = [
makeEntry({ id: "a", docType: "pre_photo" }),
makeEntry({ id: "b" }),
];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["b"]));
});
it("excludes non-done entries", () => {
const entries = [
makeEntry({ id: "a", status: "error" }),
makeEntry({ id: "b" }),
];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["b"]));
});
it("returns empty set when all classified", () => {
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
expect(selectAllUnclassified(entries)).toEqual(new Set());
});
});
// ── getClassifiedCount ───────────────────────────────────────────────────────
describe("getClassifiedCount", () => {
it("counts done entries with a docType", () => {
const entries = [
makeEntry({ id: "a", docType: "pre_photo" }),
makeEntry({ id: "b" }),
];
expect(getClassifiedCount(entries)).toBe(1);
});
it("returns 0 when none classified", () => {
expect(getClassifiedCount([makeEntry(), makeEntry({ id: "2" })])).toBe(0);
});
it("excludes non-done entries from count", () => {
const entries = [makeEntry({ id: "a", status: "error", docType: "pre_photo" })];
expect(getClassifiedCount(entries)).toBe(0);
});
});
// ── getUnclassifiedIds ───────────────────────────────────────────────────────
describe("getUnclassifiedIds", () => {
it("returns IDs of done entries with empty docType", () => {
const entries = [
makeEntry({ id: "a" }),
makeEntry({ id: "b", docType: "pre_photo" }),
];
expect(getUnclassifiedIds(entries)).toEqual(["a"]);
});
it("returns empty array when all classified", () => {
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
expect(getUnclassifiedIds(entries)).toEqual([]);
});
});
// ── uploadedCountForType ─────────────────────────────────────────────────────
describe("uploadedCountForType", () => {
it("returns 0 when docType not in uploaded list", () => {
expect(uploadedCountForType(["post_photo"], "pre_photo")).toBe(0);
});
it("counts occurrences of docType", () => {
expect(uploadedCountForType(["pre_photo", "pre_photo", "post_photo"], "pre_photo")).toBe(2);
});
it("returns 0 for empty list", () => {
expect(uploadedCountForType([], "pre_photo")).toBe(0);
});
it("exact match only — partial strings don't count", () => {
expect(uploadedCountForType(["pre_photo_extra"], "pre_photo")).toBe(0);
});
});

View file

@ -0,0 +1,33 @@
export type ClassifyEntry = {
id: string;
docType: string;
status: "queued" | "uploading" | "done" | "error";
};
export function applyBulkDocType<T extends ClassifyEntry>(
entries: T[],
selectedIds: Set<string>,
docType: string,
): T[] {
return entries.map((e) =>
selectedIds.has(e.id) && e.status === "done" ? { ...e, docType } : e,
);
}
export function selectAllUnclassified(entries: ClassifyEntry[]): Set<string> {
return new Set(
entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id),
);
}
export function getClassifiedCount(entries: ClassifyEntry[]): number {
return entries.filter((e) => e.status === "done" && e.docType !== "").length;
}
export function getUnclassifiedIds(entries: ClassifyEntry[]): string[] {
return entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id);
}
export function uploadedCountForType(uploadedDocs: string[], docType: string): number {
return uploadedDocs.filter((d) => d === docType).length;
}